diff options
Diffstat (limited to 'buildbot/buildbot/slave/commands.py')
-rw-r--r-- | buildbot/buildbot/slave/commands.py | 2788 |
1 files changed, 2788 insertions, 0 deletions
diff --git a/buildbot/buildbot/slave/commands.py b/buildbot/buildbot/slave/commands.py new file mode 100644 index 0000000..45b9e99 --- /dev/null +++ b/buildbot/buildbot/slave/commands.py @@ -0,0 +1,2788 @@ +# -*- test-case-name: buildbot.test.test_slavecommand -*- + +import os, re, signal, shutil, types, time +from stat import ST_CTIME, ST_MTIME, ST_SIZE + +from zope.interface import implements +from twisted.internet.protocol import ProcessProtocol +from twisted.internet import reactor, defer, task +from twisted.python import log, failure, runtime +from twisted.python.procutils import which + +from buildbot.slave.interfaces import ISlaveCommand +from buildbot.slave.registry import registerSlaveCommand + +# this used to be a CVS $-style "Revision" auto-updated keyword, but since I +# moved to Darcs as the primary repository, this is updated manually each +# time this file is changed. The last cvs_ver that was here was 1.51 . +command_version = "2.8" + +# version history: +# >=1.17: commands are interruptable +# >=1.28: Arch understands 'revision', added Bazaar +# >=1.33: Source classes understand 'retry' +# >=1.39: Source classes correctly handle changes in branch (except Git) +# Darcs accepts 'revision' (now all do but Git) (well, and P4Sync) +# Arch/Baz should accept 'build-config' +# >=1.51: (release 0.7.3) +# >= 2.1: SlaveShellCommand now accepts 'initial_stdin', 'keep_stdin_open', +# and 'logfiles'. It now sends 'log' messages in addition to +# stdout/stdin/header/rc. It acquired writeStdin/closeStdin methods, +# but these are not remotely callable yet. +# (not externally visible: ShellCommandPP has writeStdin/closeStdin. +# ShellCommand accepts new arguments (logfiles=, initialStdin=, +# keepStdinOpen=) and no longer accepts stdin=) +# (release 0.7.4) +# >= 2.2: added monotone, uploadFile, and downloadFile (release 0.7.5) +# >= 2.3: added bzr (release 0.7.6) +# >= 2.4: Git understands 'revision' and branches +# >= 2.5: workaround added for remote 'hg clone --rev REV' when hg<0.9.2 +# >= 2.6: added uploadDirectory +# >= 2.7: added usePTY option to SlaveShellCommand +# >= 2.8: added username and password args to SVN class + +class CommandInterrupted(Exception): + pass +class TimeoutError(Exception): + pass + +class Obfuscated: + """An obfuscated string in a command""" + def __init__(self, real, fake): + self.real = real + self.fake = fake + + def __str__(self): + return self.fake + + def __repr__(self): + return `self.fake` + + def get_real(command): + rv = command + if type(command) == types.ListType: + rv = [] + for elt in command: + if isinstance(elt, Obfuscated): + rv.append(elt.real) + else: + rv.append(elt) + return rv + get_real = staticmethod(get_real) + + def get_fake(command): + rv = command + if type(command) == types.ListType: + rv = [] + for elt in command: + if isinstance(elt, Obfuscated): + rv.append(elt.fake) + else: + rv.append(elt) + return rv + get_fake = staticmethod(get_fake) + +class AbandonChain(Exception): + """A series of chained steps can raise this exception to indicate that + one of the intermediate ShellCommands has failed, such that there is no + point in running the remainder. 'rc' should be the non-zero exit code of + the failing ShellCommand.""" + + def __repr__(self): + return "<AbandonChain rc=%s>" % self.args[0] + +def getCommand(name): + possibles = which(name) + if not possibles: + raise RuntimeError("Couldn't find executable for '%s'" % name) + return possibles[0] + +def rmdirRecursive(dir): + """This is a replacement for shutil.rmtree that works better under + windows. Thanks to Bear at the OSAF for the code.""" + if not os.path.exists(dir): + return + + if os.path.islink(dir): + os.remove(dir) + return + + # Verify the directory is read/write/execute for the current user + os.chmod(dir, 0700) + + for name in os.listdir(dir): + full_name = os.path.join(dir, name) + # on Windows, if we don't have write permission we can't remove + # the file/directory either, so turn that on + if os.name == 'nt': + if not os.access(full_name, os.W_OK): + # I think this is now redundant, but I don't have an NT + # machine to test on, so I'm going to leave it in place + # -warner + os.chmod(full_name, 0600) + + if os.path.isdir(full_name): + rmdirRecursive(full_name) + else: + os.chmod(full_name, 0700) + os.remove(full_name) + os.rmdir(dir) + +class ShellCommandPP(ProcessProtocol): + debug = False + + def __init__(self, command): + self.command = command + self.pending_stdin = "" + self.stdin_finished = False + + def writeStdin(self, data): + assert not self.stdin_finished + if self.connected: + self.transport.write(data) + else: + self.pending_stdin += data + + def closeStdin(self): + if self.connected: + if self.debug: log.msg(" closing stdin") + self.transport.closeStdin() + self.stdin_finished = True + + def connectionMade(self): + if self.debug: + log.msg("ShellCommandPP.connectionMade") + if not self.command.process: + if self.debug: + log.msg(" assigning self.command.process: %s" % + (self.transport,)) + self.command.process = self.transport + + # TODO: maybe we shouldn't close stdin when using a PTY. I can't test + # this yet, recent debian glibc has a bug which causes thread-using + # test cases to SIGHUP trial, and the workaround is to either run + # the whole test with /bin/sh -c " ".join(argv) (way gross) or to + # not use a PTY. Once the bug is fixed, I'll be able to test what + # happens when you close stdin on a pty. My concern is that it will + # SIGHUP the child (since we are, in a sense, hanging up on them). + # But it may well be that keeping stdout open prevents the SIGHUP + # from being sent. + #if not self.command.usePTY: + + if self.pending_stdin: + if self.debug: log.msg(" writing to stdin") + self.transport.write(self.pending_stdin) + if self.stdin_finished: + if self.debug: log.msg(" closing stdin") + self.transport.closeStdin() + + def outReceived(self, data): + if self.debug: + log.msg("ShellCommandPP.outReceived") + self.command.addStdout(data) + + def errReceived(self, data): + if self.debug: + log.msg("ShellCommandPP.errReceived") + self.command.addStderr(data) + + def processEnded(self, status_object): + if self.debug: + log.msg("ShellCommandPP.processEnded", status_object) + # status_object is a Failure wrapped around an + # error.ProcessTerminated or and error.ProcessDone. + # requires twisted >= 1.0.4 to overcome a bug in process.py + sig = status_object.value.signal + rc = status_object.value.exitCode + self.command.finished(sig, rc) + +class LogFileWatcher: + POLL_INTERVAL = 2 + + def __init__(self, command, name, logfile): + self.command = command + self.name = name + self.logfile = logfile + log.msg("LogFileWatcher created to watch %s" % logfile) + # we are created before the ShellCommand starts. If the logfile we're + # supposed to be watching already exists, record its size and + # ctime/mtime so we can tell when it starts to change. + self.old_logfile_stats = self.statFile() + self.started = False + + # every 2 seconds we check on the file again + self.poller = task.LoopingCall(self.poll) + + def start(self): + self.poller.start(self.POLL_INTERVAL).addErrback(self._cleanupPoll) + + def _cleanupPoll(self, err): + log.err(err, msg="Polling error") + self.poller = None + + def stop(self): + self.poll() + if self.poller is not None: + self.poller.stop() + if self.started: + self.f.close() + + def statFile(self): + if os.path.exists(self.logfile): + s = os.stat(self.logfile) + return (s[ST_CTIME], s[ST_MTIME], s[ST_SIZE]) + return None + + def poll(self): + if not self.started: + s = self.statFile() + if s == self.old_logfile_stats: + return # not started yet + if not s: + # the file was there, but now it's deleted. Forget about the + # initial state, clearly the process has deleted the logfile + # in preparation for creating a new one. + self.old_logfile_stats = None + return # no file to work with + self.f = open(self.logfile, "rb") + self.started = True + self.f.seek(self.f.tell(), 0) + while True: + data = self.f.read(10000) + if not data: + return + self.command.addLogfile(self.name, data) + + +class ShellCommand: + # This is a helper class, used by SlaveCommands to run programs in a + # child shell. + + notreally = False + BACKUP_TIMEOUT = 5 + KILL = "KILL" + CHUNK_LIMIT = 128*1024 + + # For sending elapsed time: + startTime = None + elapsedTime = None + # I wish we had easy access to CLOCK_MONOTONIC in Python: + # http://www.opengroup.org/onlinepubs/000095399/functions/clock_getres.html + # Then changes to the system clock during a run wouldn't effect the "elapsed + # time" results. + + def __init__(self, builder, command, + workdir, environ=None, + sendStdout=True, sendStderr=True, sendRC=True, + timeout=None, initialStdin=None, keepStdinOpen=False, + keepStdout=False, keepStderr=False, logEnviron=True, + logfiles={}, usePTY="slave-config"): + """ + + @param keepStdout: if True, we keep a copy of all the stdout text + that we've seen. This copy is available in + self.stdout, which can be read after the command + has finished. + @param keepStderr: same, for stderr + + @param usePTY: "slave-config" -> use the SlaveBuilder's usePTY; + otherwise, true to use a PTY, false to not use a PTY. + """ + + self.builder = builder + self.command = Obfuscated.get_real(command) + self.fake_command = Obfuscated.get_fake(command) + self.sendStdout = sendStdout + self.sendStderr = sendStderr + self.sendRC = sendRC + self.logfiles = logfiles + self.workdir = workdir + self.environ = os.environ.copy() + if environ: + if environ.has_key('PYTHONPATH'): + ppath = environ['PYTHONPATH'] + # Need to do os.pathsep translation. We could either do that + # by replacing all incoming ':'s with os.pathsep, or by + # accepting lists. I like lists better. + if not isinstance(ppath, str): + # If it's not a string, treat it as a sequence to be + # turned in to a string. + ppath = os.pathsep.join(ppath) + + if self.environ.has_key('PYTHONPATH'): + # special case, prepend the builder's items to the + # existing ones. This will break if you send over empty + # strings, so don't do that. + ppath = ppath + os.pathsep + self.environ['PYTHONPATH'] + + environ['PYTHONPATH'] = ppath + + self.environ.update(environ) + self.initialStdin = initialStdin + self.keepStdinOpen = keepStdinOpen + self.logEnviron = logEnviron + self.timeout = timeout + self.timer = None + self.keepStdout = keepStdout + self.keepStderr = keepStderr + + + if usePTY == "slave-config": + self.usePTY = self.builder.usePTY + else: + self.usePTY = usePTY + + # usePTY=True is a convenience for cleaning up all children and + # grandchildren of a hung command. Fall back to usePTY=False on systems + # and in situations where ptys cause problems. PTYs are posix-only, + # and for .closeStdin to matter, we must use a pipe, not a PTY + if runtime.platformType != "posix" or initialStdin is not None: + if self.usePTY and usePTY != "slave-config": + self.sendStatus({'header': "WARNING: disabling usePTY for this command"}) + self.usePTY = False + + self.logFileWatchers = [] + for name,filename in self.logfiles.items(): + w = LogFileWatcher(self, name, + os.path.join(self.workdir, filename)) + self.logFileWatchers.append(w) + + def __repr__(self): + return "<slavecommand.ShellCommand '%s'>" % self.fake_command + + def sendStatus(self, status): + self.builder.sendUpdate(status) + + def start(self): + # return a Deferred which fires (with the exit code) when the command + # completes + if self.keepStdout: + self.stdout = "" + if self.keepStderr: + self.stderr = "" + self.deferred = defer.Deferred() + try: + self._startCommand() + except: + log.msg("error in ShellCommand._startCommand") + log.err() + # pretend it was a shell error + self.deferred.errback(AbandonChain(-1)) + return self.deferred + + def _startCommand(self): + # ensure workdir exists + if not os.path.isdir(self.workdir): + os.makedirs(self.workdir) + log.msg("ShellCommand._startCommand") + if self.notreally: + self.sendStatus({'header': "command '%s' in dir %s" % \ + (self.fake_command, self.workdir)}) + self.sendStatus({'header': "(not really)\n"}) + self.finished(None, 0) + return + + self.pp = ShellCommandPP(self) + + if type(self.command) in types.StringTypes: + if runtime.platformType == 'win32': + argv = os.environ['COMSPEC'].split() # allow %COMSPEC% to have args + if '/c' not in argv: argv += ['/c'] + argv += [self.command] + else: + # for posix, use /bin/sh. for other non-posix, well, doesn't + # hurt to try + argv = ['/bin/sh', '-c', self.command] + display = self.fake_command + else: + if runtime.platformType == 'win32': + argv = os.environ['COMSPEC'].split() # allow %COMSPEC% to have args + if '/c' not in argv: argv += ['/c'] + argv += list(self.command) + else: + argv = self.command + display = " ".join(self.fake_command) + + # $PWD usually indicates the current directory; spawnProcess may not + # update this value, though, so we set it explicitly here. + self.environ['PWD'] = os.path.abspath(self.workdir) + + # self.stdin is handled in ShellCommandPP.connectionMade + + # first header line is the command in plain text, argv joined with + # spaces. You should be able to cut-and-paste this into a shell to + # obtain the same results. If there are spaces in the arguments, too + # bad. + log.msg(" " + display) + self.sendStatus({'header': display+"\n"}) + + # then comes the secondary information + msg = " in dir %s" % (self.workdir,) + if self.timeout: + msg += " (timeout %d secs)" % (self.timeout,) + log.msg(" " + msg) + self.sendStatus({'header': msg+"\n"}) + + msg = " watching logfiles %s" % (self.logfiles,) + log.msg(" " + msg) + self.sendStatus({'header': msg+"\n"}) + + # then the obfuscated command array for resolving unambiguity + msg = " argv: %s" % (self.fake_command,) + log.msg(" " + msg) + self.sendStatus({'header': msg+"\n"}) + + # then the environment, since it sometimes causes problems + if self.logEnviron: + msg = " environment:\n" + env_names = self.environ.keys() + env_names.sort() + for name in env_names: + msg += " %s=%s\n" % (name, self.environ[name]) + log.msg(" environment: %s" % (self.environ,)) + self.sendStatus({'header': msg}) + + if self.initialStdin: + msg = " writing %d bytes to stdin" % len(self.initialStdin) + log.msg(" " + msg) + self.sendStatus({'header': msg+"\n"}) + + if self.keepStdinOpen: + msg = " leaving stdin open" + else: + msg = " closing stdin" + log.msg(" " + msg) + self.sendStatus({'header': msg+"\n"}) + + msg = " using PTY: %s" % bool(self.usePTY) + log.msg(" " + msg) + self.sendStatus({'header': msg+"\n"}) + + # this will be buffered until connectionMade is called + if self.initialStdin: + self.pp.writeStdin(self.initialStdin) + if not self.keepStdinOpen: + self.pp.closeStdin() + + # win32eventreactor's spawnProcess (under twisted <= 2.0.1) returns + # None, as opposed to all the posixbase-derived reactors (which + # return the new Process object). This is a nuisance. We can make up + # for it by having the ProcessProtocol give us their .transport + # attribute after they get one. I'd prefer to get it from + # spawnProcess because I'm concerned about returning from this method + # without having a valid self.process to work with. (if kill() were + # called right after we return, but somehow before connectionMade + # were called, then kill() would blow up). + self.process = None + self.startTime = time.time() + p = reactor.spawnProcess(self.pp, argv[0], argv, + self.environ, + self.workdir, + usePTY=self.usePTY) + # connectionMade might have been called during spawnProcess + if not self.process: + self.process = p + + # connectionMade also closes stdin as long as we're not using a PTY. + # This is intended to kill off inappropriately interactive commands + # better than the (long) hung-command timeout. ProcessPTY should be + # enhanced to allow the same childFDs argument that Process takes, + # which would let us connect stdin to /dev/null . + + if self.timeout: + self.timer = reactor.callLater(self.timeout, self.doTimeout) + + for w in self.logFileWatchers: + w.start() + + + def _chunkForSend(self, data): + # limit the chunks that we send over PB to 128k, since it has a + # hardwired string-size limit of 640k. + LIMIT = self.CHUNK_LIMIT + for i in range(0, len(data), LIMIT): + yield data[i:i+LIMIT] + + def addStdout(self, data): + if self.sendStdout: + for chunk in self._chunkForSend(data): + self.sendStatus({'stdout': chunk}) + if self.keepStdout: + self.stdout += data + if self.timer: + self.timer.reset(self.timeout) + + def addStderr(self, data): + if self.sendStderr: + for chunk in self._chunkForSend(data): + self.sendStatus({'stderr': chunk}) + if self.keepStderr: + self.stderr += data + if self.timer: + self.timer.reset(self.timeout) + + def addLogfile(self, name, data): + for chunk in self._chunkForSend(data): + self.sendStatus({'log': (name, chunk)}) + if self.timer: + self.timer.reset(self.timeout) + + def finished(self, sig, rc): + self.elapsedTime = time.time() - self.startTime + log.msg("command finished with signal %s, exit code %s, elapsedTime: %0.6f" % (sig,rc,self.elapsedTime)) + for w in self.logFileWatchers: + # this will send the final updates + w.stop() + if sig is not None: + rc = -1 + if self.sendRC: + if sig is not None: + self.sendStatus( + {'header': "process killed by signal %d\n" % sig}) + self.sendStatus({'rc': rc}) + self.sendStatus({'header': "elapsedTime=%0.6f\n" % self.elapsedTime}) + if self.timer: + self.timer.cancel() + self.timer = None + d = self.deferred + self.deferred = None + if d: + d.callback(rc) + else: + log.msg("Hey, command %s finished twice" % self) + + def failed(self, why): + log.msg("ShellCommand.failed: command failed: %s" % (why,)) + if self.timer: + self.timer.cancel() + self.timer = None + d = self.deferred + self.deferred = None + if d: + d.errback(why) + else: + log.msg("Hey, command %s finished twice" % self) + + def doTimeout(self): + self.timer = None + msg = "command timed out: %d seconds without output" % self.timeout + self.kill(msg) + + def kill(self, msg): + # This may be called by the timeout, or when the user has decided to + # abort this build. + if self.timer: + self.timer.cancel() + self.timer = None + if hasattr(self.process, "pid"): + msg += ", killing pid %d" % self.process.pid + log.msg(msg) + self.sendStatus({'header': "\n" + msg + "\n"}) + + hit = 0 + if runtime.platformType == "posix": + try: + # really want to kill off all child processes too. Process + # Groups are ideal for this, but that requires + # spawnProcess(usePTY=1). Try both ways in case process was + # not started that way. + + # the test suite sets self.KILL=None to tell us we should + # only pretend to kill the child. This lets us test the + # backup timer. + + sig = None + if self.KILL is not None: + sig = getattr(signal, "SIG"+ self.KILL, None) + + if self.KILL == None: + log.msg("self.KILL==None, only pretending to kill child") + elif sig is None: + log.msg("signal module is missing SIG%s" % self.KILL) + elif not hasattr(os, "kill"): + log.msg("os module is missing the 'kill' function") + else: + log.msg("trying os.kill(-pid, %d)" % (sig,)) + # TODO: maybe use os.killpg instead of a negative pid? + os.kill(-self.process.pid, sig) + log.msg(" signal %s sent successfully" % sig) + hit = 1 + except OSError: + # probably no-such-process, maybe because there is no process + # group + pass + if not hit: + try: + if self.KILL is None: + log.msg("self.KILL==None, only pretending to kill child") + else: + log.msg("trying process.signalProcess('KILL')") + self.process.signalProcess(self.KILL) + log.msg(" signal %s sent successfully" % (self.KILL,)) + hit = 1 + except OSError: + # could be no-such-process, because they finished very recently + pass + if not hit: + log.msg("signalProcess/os.kill failed both times") + + if runtime.platformType == "posix": + # we only do this under posix because the win32eventreactor + # blocks here until the process has terminated, while closing + # stderr. This is weird. + self.pp.transport.loseConnection() + + # finished ought to be called momentarily. Just in case it doesn't, + # set a timer which will abandon the command. + self.timer = reactor.callLater(self.BACKUP_TIMEOUT, + self.doBackupTimeout) + + def doBackupTimeout(self): + log.msg("we tried to kill the process, and it wouldn't die.." + " finish anyway") + self.timer = None + self.sendStatus({'header': "SIGKILL failed to kill process\n"}) + if self.sendRC: + self.sendStatus({'header': "using fake rc=-1\n"}) + self.sendStatus({'rc': -1}) + self.failed(TimeoutError("SIGKILL failed to kill process")) + + + def writeStdin(self, data): + self.pp.writeStdin(data) + + def closeStdin(self): + self.pp.closeStdin() + + +class Command: + implements(ISlaveCommand) + + """This class defines one command that can be invoked by the build master. + The command is executed on the slave side, and always sends back a + completion message when it finishes. It may also send intermediate status + as it runs (by calling builder.sendStatus). Some commands can be + interrupted (either by the build master or a local timeout), in which + case the step is expected to complete normally with a status message that + indicates an error occurred. + + These commands are used by BuildSteps on the master side. Each kind of + BuildStep uses a single Command. The slave must implement all the + Commands required by the set of BuildSteps used for any given build: + this is checked at startup time. + + All Commands are constructed with the same signature: + c = CommandClass(builder, args) + where 'builder' is the parent SlaveBuilder object, and 'args' is a + dict that is interpreted per-command. + + The setup(args) method is available for setup, and is run from __init__. + + The Command is started with start(). This method must be implemented in a + subclass, and it should return a Deferred. When your step is done, you + should fire the Deferred (the results are not used). If the command is + interrupted, it should fire the Deferred anyway. + + While the command runs. it may send status messages back to the + buildmaster by calling self.sendStatus(statusdict). The statusdict is + interpreted by the master-side BuildStep however it likes. + + A separate completion message is sent when the deferred fires, which + indicates that the Command has finished, but does not carry any status + data. If the Command needs to return an exit code of some sort, that + should be sent as a regular status message before the deferred is fired . + Once builder.commandComplete has been run, no more status messages may be + sent. + + If interrupt() is called, the Command should attempt to shut down as + quickly as possible. Child processes should be killed, new ones should + not be started. The Command should send some kind of error status update, + then complete as usual by firing the Deferred. + + .interrupted should be set by interrupt(), and can be tested to avoid + sending multiple error status messages. + + If .running is False, the bot is shutting down (or has otherwise lost the + connection to the master), and should not send any status messages. This + is checked in Command.sendStatus . + + """ + + # builder methods: + # sendStatus(dict) (zero or more) + # commandComplete() or commandInterrupted() (one, at end) + + debug = False + interrupted = False + running = False # set by Builder, cleared on shutdown or when the + # Deferred fires + + def __init__(self, builder, stepId, args): + self.builder = builder + self.stepId = stepId # just for logging + self.args = args + self.setup(args) + + def setup(self, args): + """Override this in a subclass to extract items from the args dict.""" + pass + + def doStart(self): + self.running = True + d = defer.maybeDeferred(self.start) + d.addBoth(self.commandComplete) + return d + + def start(self): + """Start the command. This method should return a Deferred that will + fire when the command has completed. The Deferred's argument will be + ignored. + + This method should be overridden by subclasses.""" + raise NotImplementedError, "You must implement this in a subclass" + + def sendStatus(self, status): + """Send a status update to the master.""" + if self.debug: + log.msg("sendStatus", status) + if not self.running: + log.msg("would sendStatus but not .running") + return + self.builder.sendUpdate(status) + + def doInterrupt(self): + self.running = False + self.interrupt() + + def interrupt(self): + """Override this in a subclass to allow commands to be interrupted. + May be called multiple times, test and set self.interrupted=True if + this matters.""" + pass + + def commandComplete(self, res): + self.running = False + return res + + # utility methods, mostly used by SlaveShellCommand and the like + + def _abandonOnFailure(self, rc): + if type(rc) is not int: + log.msg("weird, _abandonOnFailure was given rc=%s (%s)" % \ + (rc, type(rc))) + assert isinstance(rc, int) + if rc != 0: + raise AbandonChain(rc) + return rc + + def _sendRC(self, res): + self.sendStatus({'rc': 0}) + + def _checkAbandoned(self, why): + log.msg("_checkAbandoned", why) + why.trap(AbandonChain) + log.msg(" abandoning chain", why.value) + self.sendStatus({'rc': why.value.args[0]}) + return None + + + +class SlaveFileUploadCommand(Command): + """ + Upload a file from slave to build master + Arguments: + + - ['workdir']: base directory to use + - ['slavesrc']: name of the slave-side file to read from + - ['writer']: RemoteReference to a transfer._FileWriter object + - ['maxsize']: max size (in bytes) of file to write + - ['blocksize']: max size for each data block + """ + debug = False + + def setup(self, args): + self.workdir = args['workdir'] + self.filename = args['slavesrc'] + self.writer = args['writer'] + self.remaining = args['maxsize'] + self.blocksize = args['blocksize'] + self.stderr = None + self.rc = 0 + + def start(self): + if self.debug: + log.msg('SlaveFileUploadCommand started') + + # Open file + self.path = os.path.join(self.builder.basedir, + self.workdir, + os.path.expanduser(self.filename)) + try: + self.fp = open(self.path, 'rb') + if self.debug: + log.msg('Opened %r for upload' % self.path) + except: + # TODO: this needs cleanup + self.fp = None + self.stderr = 'Cannot open file %r for upload' % self.path + self.rc = 1 + if self.debug: + log.msg('Cannot open file %r for upload' % self.path) + + self.sendStatus({'header': "sending %s" % self.path}) + + d = defer.Deferred() + reactor.callLater(0, self._loop, d) + def _close(res): + # close the file, but pass through any errors from _loop + d1 = self.writer.callRemote("close") + d1.addErrback(log.err) + d1.addCallback(lambda ignored: res) + return d1 + d.addBoth(_close) + d.addBoth(self.finished) + return d + + def _loop(self, fire_when_done): + d = defer.maybeDeferred(self._writeBlock) + def _done(finished): + if finished: + fire_when_done.callback(None) + else: + self._loop(fire_when_done) + def _err(why): + fire_when_done.errback(why) + d.addCallbacks(_done, _err) + return None + + def _writeBlock(self): + """Write a block of data to the remote writer""" + + if self.interrupted or self.fp is None: + if self.debug: + log.msg('SlaveFileUploadCommand._writeBlock(): end') + return True + + length = self.blocksize + if self.remaining is not None and length > self.remaining: + length = self.remaining + + if length <= 0: + if self.stderr is None: + self.stderr = 'Maximum filesize reached, truncating file %r' \ + % self.path + self.rc = 1 + data = '' + else: + data = self.fp.read(length) + + if self.debug: + log.msg('SlaveFileUploadCommand._writeBlock(): '+ + 'allowed=%d readlen=%d' % (length, len(data))) + if len(data) == 0: + log.msg("EOF: callRemote(close)") + return True + + if self.remaining is not None: + self.remaining = self.remaining - len(data) + assert self.remaining >= 0 + d = self.writer.callRemote('write', data) + d.addCallback(lambda res: False) + return d + + def interrupt(self): + if self.debug: + log.msg('interrupted') + if self.interrupted: + return + if self.stderr is None: + self.stderr = 'Upload of %r interrupted' % self.path + self.rc = 1 + self.interrupted = True + # the next _writeBlock call will notice the .interrupted flag + + def finished(self, res): + if self.debug: + log.msg('finished: stderr=%r, rc=%r' % (self.stderr, self.rc)) + if self.stderr is None: + self.sendStatus({'rc': self.rc}) + else: + self.sendStatus({'stderr': self.stderr, 'rc': self.rc}) + return res + +registerSlaveCommand("uploadFile", SlaveFileUploadCommand, command_version) + + +class SlaveDirectoryUploadCommand(Command): + """ + Upload a directory from slave to build master + Arguments: + + - ['workdir']: base directory to use + - ['slavesrc']: name of the slave-side directory to read from + - ['writer']: RemoteReference to a transfer._DirectoryWriter object + - ['maxsize']: max size (in bytes) of file to write + - ['blocksize']: max size for each data block + """ + debug = True + + def setup(self, args): + self.workdir = args['workdir'] + self.dirname = args['slavesrc'] + self.writer = args['writer'] + self.remaining = args['maxsize'] + self.blocksize = args['blocksize'] + self.stderr = None + self.rc = 0 + + def start(self): + if self.debug: + log.msg('SlaveDirectoryUploadCommand started') + + # create some lists with all files and directories + foundFiles = [] + foundDirs = [] + + self.baseRoot = os.path.join(self.builder.basedir, + self.workdir, + os.path.expanduser(self.dirname)) + if self.debug: + log.msg("baseRoot: %r" % self.baseRoot) + + for root, dirs, files in os.walk(self.baseRoot): + tempRoot = root + relRoot = '' + while (tempRoot != self.baseRoot): + tempRoot, tempRelRoot = os.path.split(tempRoot) + relRoot = os.path.join(tempRelRoot, relRoot) + for name in files: + foundFiles.append(os.path.join(relRoot, name)) + for directory in dirs: + foundDirs.append(os.path.join(relRoot, directory)) + + if self.debug: + log.msg("foundDirs: %s" % (str(foundDirs))) + log.msg("foundFiles: %s" % (str(foundFiles))) + + # create all directories on the master, to catch also empty ones + for dirname in foundDirs: + self.writer.callRemote("createdir", dirname) + + for filename in foundFiles: + self._writeFile(filename) + + return None + + def _writeFile(self, filename): + """Write a file to the remote writer""" + + log.msg("_writeFile: %r" % (filename)) + self.writer.callRemote('open', filename) + data = open(os.path.join(self.baseRoot, filename), "r").read() + self.writer.callRemote('write', data) + self.writer.callRemote('close') + return None + + def interrupt(self): + if self.debug: + log.msg('interrupted') + if self.interrupted: + return + if self.stderr is None: + self.stderr = 'Upload of %r interrupted' % self.path + self.rc = 1 + self.interrupted = True + # the next _writeBlock call will notice the .interrupted flag + + def finished(self, res): + if self.debug: + log.msg('finished: stderr=%r, rc=%r' % (self.stderr, self.rc)) + if self.stderr is None: + self.sendStatus({'rc': self.rc}) + else: + self.sendStatus({'stderr': self.stderr, 'rc': self.rc}) + return res + +registerSlaveCommand("uploadDirectory", SlaveDirectoryUploadCommand, command_version) + + +class SlaveFileDownloadCommand(Command): + """ + Download a file from master to slave + Arguments: + + - ['workdir']: base directory to use + - ['slavedest']: name of the slave-side file to be created + - ['reader']: RemoteReference to a transfer._FileReader object + - ['maxsize']: max size (in bytes) of file to write + - ['blocksize']: max size for each data block + - ['mode']: access mode for the new file + """ + debug = False + + def setup(self, args): + self.workdir = args['workdir'] + self.filename = args['slavedest'] + self.reader = args['reader'] + self.bytes_remaining = args['maxsize'] + self.blocksize = args['blocksize'] + self.mode = args['mode'] + self.stderr = None + self.rc = 0 + + def start(self): + if self.debug: + log.msg('SlaveFileDownloadCommand starting') + + # Open file + self.path = os.path.join(self.builder.basedir, + self.workdir, + os.path.expanduser(self.filename)) + + dirname = os.path.dirname(self.path) + if not os.path.exists(dirname): + os.makedirs(dirname) + + try: + self.fp = open(self.path, 'wb') + if self.debug: + log.msg('Opened %r for download' % self.path) + if self.mode is not None: + # note: there is a brief window during which the new file + # will have the buildslave's default (umask) mode before we + # set the new one. Don't use this mode= feature to keep files + # private: use the buildslave's umask for that instead. (it + # is possible to call os.umask() before and after the open() + # call, but cleaning up from exceptions properly is more of a + # nuisance that way). + os.chmod(self.path, self.mode) + except IOError: + # TODO: this still needs cleanup + self.fp = None + self.stderr = 'Cannot open file %r for download' % self.path + self.rc = 1 + if self.debug: + log.msg('Cannot open file %r for download' % self.path) + + d = defer.Deferred() + reactor.callLater(0, self._loop, d) + def _close(res): + # close the file, but pass through any errors from _loop + d1 = self.reader.callRemote('close') + d1.addErrback(log.err) + d1.addCallback(lambda ignored: res) + return d1 + d.addBoth(_close) + d.addBoth(self.finished) + return d + + def _loop(self, fire_when_done): + d = defer.maybeDeferred(self._readBlock) + def _done(finished): + if finished: + fire_when_done.callback(None) + else: + self._loop(fire_when_done) + def _err(why): + fire_when_done.errback(why) + d.addCallbacks(_done, _err) + return None + + def _readBlock(self): + """Read a block of data from the remote reader.""" + + if self.interrupted or self.fp is None: + if self.debug: + log.msg('SlaveFileDownloadCommand._readBlock(): end') + return True + + length = self.blocksize + if self.bytes_remaining is not None and length > self.bytes_remaining: + length = self.bytes_remaining + + if length <= 0: + if self.stderr is None: + self.stderr = 'Maximum filesize reached, truncating file %r' \ + % self.path + self.rc = 1 + return True + else: + d = self.reader.callRemote('read', length) + d.addCallback(self._writeData) + return d + + def _writeData(self, data): + if self.debug: + log.msg('SlaveFileDownloadCommand._readBlock(): readlen=%d' % + len(data)) + if len(data) == 0: + return True + + if self.bytes_remaining is not None: + self.bytes_remaining = self.bytes_remaining - len(data) + assert self.bytes_remaining >= 0 + self.fp.write(data) + return False + + def interrupt(self): + if self.debug: + log.msg('interrupted') + if self.interrupted: + return + if self.stderr is None: + self.stderr = 'Download of %r interrupted' % self.path + self.rc = 1 + self.interrupted = True + # now we wait for the next read request to return. _readBlock will + # abandon the file when it sees self.interrupted set. + + def finished(self, res): + if self.fp is not None: + self.fp.close() + + if self.debug: + log.msg('finished: stderr=%r, rc=%r' % (self.stderr, self.rc)) + if self.stderr is None: + self.sendStatus({'rc': self.rc}) + else: + self.sendStatus({'stderr': self.stderr, 'rc': self.rc}) + return res + +registerSlaveCommand("downloadFile", SlaveFileDownloadCommand, command_version) + + + +class SlaveShellCommand(Command): + """This is a Command which runs a shell command. The args dict contains + the following keys: + + - ['command'] (required): a shell command to run. If this is a string, + it will be run with /bin/sh (['/bin/sh', + '-c', command]). If it is a list + (preferred), it will be used directly. + - ['workdir'] (required): subdirectory in which the command will be + run, relative to the builder dir + - ['env']: a dict of environment variables to augment/replace + os.environ . PYTHONPATH is treated specially, and + should be a list of path components to be prepended to + any existing PYTHONPATH environment variable. + - ['initial_stdin']: a string which will be written to the command's + stdin as soon as it starts + - ['keep_stdin_open']: unless True, the command's stdin will be + closed as soon as initial_stdin has been + written. Set this to True if you plan to write + to stdin after the command has been started. + - ['want_stdout']: 0 if stdout should be thrown away + - ['want_stderr']: 0 if stderr should be thrown away + - ['usePTY']: True or False if the command should use a PTY (defaults to + configuration of the slave) + - ['not_really']: 1 to skip execution and return rc=0 + - ['timeout']: seconds of silence to tolerate before killing command + - ['logfiles']: dict mapping LogFile name to the workdir-relative + filename of a local log file. This local file will be + watched just like 'tail -f', and all changes will be + written to 'log' status updates. + + ShellCommand creates the following status messages: + - {'stdout': data} : when stdout data is available + - {'stderr': data} : when stderr data is available + - {'header': data} : when headers (command start/stop) are available + - {'log': (logfile_name, data)} : when log files have new contents + - {'rc': rc} : when the process has terminated + """ + + def start(self): + args = self.args + # args['workdir'] is relative to Builder directory, and is required. + assert args['workdir'] is not None + workdir = os.path.join(self.builder.basedir, args['workdir']) + + c = ShellCommand(self.builder, args['command'], + workdir, environ=args.get('env'), + timeout=args.get('timeout', None), + sendStdout=args.get('want_stdout', True), + sendStderr=args.get('want_stderr', True), + sendRC=True, + initialStdin=args.get('initial_stdin'), + keepStdinOpen=args.get('keep_stdin_open'), + logfiles=args.get('logfiles', {}), + usePTY=args.get('usePTY', "slave-config"), + ) + self.command = c + d = self.command.start() + return d + + def interrupt(self): + self.interrupted = True + self.command.kill("command interrupted") + + def writeStdin(self, data): + self.command.writeStdin(data) + + def closeStdin(self): + self.command.closeStdin() + +registerSlaveCommand("shell", SlaveShellCommand, command_version) + + +class DummyCommand(Command): + """ + I am a dummy no-op command that by default takes 5 seconds to complete. + See L{buildbot.steps.dummy.RemoteDummy} + """ + + def start(self): + self.d = defer.Deferred() + log.msg(" starting dummy command [%s]" % self.stepId) + self.timer = reactor.callLater(1, self.doStatus) + return self.d + + def interrupt(self): + if self.interrupted: + return + self.timer.cancel() + self.timer = None + self.interrupted = True + self.finished() + + def doStatus(self): + log.msg(" sending intermediate status") + self.sendStatus({'stdout': 'data'}) + timeout = self.args.get('timeout', 5) + 1 + self.timer = reactor.callLater(timeout - 1, self.finished) + + def finished(self): + log.msg(" dummy command finished [%s]" % self.stepId) + if self.interrupted: + self.sendStatus({'rc': 1}) + else: + self.sendStatus({'rc': 0}) + self.d.callback(0) + +registerSlaveCommand("dummy", DummyCommand, command_version) + + +# this maps handle names to a callable. When the WaitCommand starts, this +# callable is invoked with no arguments. It should return a Deferred. When +# that Deferred fires, our WaitCommand will finish. +waitCommandRegistry = {} + +class WaitCommand(Command): + """ + I am a dummy command used by the buildbot unit test suite. I want for the + unit test to tell us to finish. See L{buildbot.steps.dummy.Wait} + """ + + def start(self): + self.d = defer.Deferred() + log.msg(" starting wait command [%s]" % self.stepId) + handle = self.args['handle'] + cb = waitCommandRegistry[handle] + del waitCommandRegistry[handle] + def _called(): + log.msg(" wait-%s starting" % (handle,)) + d = cb() + def _done(res): + log.msg(" wait-%s finishing: %s" % (handle, res)) + return res + d.addBoth(_done) + d.addCallbacks(self.finished, self.failed) + reactor.callLater(0, _called) + return self.d + + def interrupt(self): + log.msg(" wait command interrupted") + if self.interrupted: + return + self.interrupted = True + self.finished("interrupted") + + def finished(self, res): + log.msg(" wait command finished [%s]" % self.stepId) + if self.interrupted: + self.sendStatus({'rc': 2}) + else: + self.sendStatus({'rc': 0}) + self.d.callback(0) + def failed(self, why): + log.msg(" wait command failed [%s]" % self.stepId) + self.sendStatus({'rc': 1}) + self.d.callback(0) + +registerSlaveCommand("dummy.wait", WaitCommand, command_version) + + +class SourceBase(Command): + """Abstract base class for Version Control System operations (checkout + and update). This class extracts the following arguments from the + dictionary received from the master: + + - ['workdir']: (required) the subdirectory where the buildable sources + should be placed + + - ['mode']: one of update/copy/clobber/export, defaults to 'update' + + - ['revision']: If not None, this is an int or string which indicates + which sources (along a time-like axis) should be used. + It is the thing you provide as the CVS -r or -D + argument. + + - ['patch']: If not None, this is a tuple of (striplevel, patch) + which contains a patch that should be applied after the + checkout has occurred. Once applied, the tree is no + longer eligible for use with mode='update', and it only + makes sense to use this in conjunction with a + ['revision'] argument. striplevel is an int, and patch + is a string in standard unified diff format. The patch + will be applied with 'patch -p%d <PATCH', with + STRIPLEVEL substituted as %d. The command will fail if + the patch process fails (rejected hunks). + + - ['timeout']: seconds of silence tolerated before we kill off the + command + + - ['retry']: If not None, this is a tuple of (delay, repeats) + which means that any failed VC updates should be + reattempted, up to REPEATS times, after a delay of + DELAY seconds. This is intended to deal with slaves + that experience transient network failures. + """ + + sourcedata = "" + + def setup(self, args): + # if we need to parse the output, use this environment. Otherwise + # command output will be in whatever the buildslave's native language + # has been set to. + self.env = os.environ.copy() + self.env['LC_MESSAGES'] = "C" + + self.workdir = args['workdir'] + self.mode = args.get('mode', "update") + self.revision = args.get('revision') + self.patch = args.get('patch') + self.timeout = args.get('timeout', 120) + self.retry = args.get('retry') + # VC-specific subclasses should override this to extract more args. + # Make sure to upcall! + + def start(self): + 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") + + d = defer.succeed(None) + self.maybeClobber(d) + 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) + + d.addCallback(self.doVC) + + if self.mode == "copy": + d.addCallback(self.doCopy) + if self.patch: + d.addCallback(self.doPatch) + d.addCallbacks(self._sendRC, self._checkAbandoned) + return d + + def maybeClobber(self, d): + # do we need to clobber anything? + if self.mode in ("copy", "clobber", "export"): + d.addCallback(self.doClobber, self.workdir) + + def interrupt(self): + self.interrupted = True + if self.command: + self.command.kill("command interrupted") + + def doVC(self, res): + if self.interrupted: + raise AbandonChain(1) + if self.sourcedirIsUpdateable() and self.sourcedataMatches(): + d = self.doVCUpdate() + d.addCallback(self.maybeDoVCFallback) + else: + d = self.doVCFull() + d.addBoth(self.maybeDoVCRetry) + d.addCallback(self._abandonOnFailure) + d.addCallback(self._handleGotRevision) + d.addCallback(self.writeSourcedata) + return d + + def sourcedataMatches(self): + try: + olddata = open(self.sourcedatafile, "r").read() + if olddata != self.sourcedata: + return False + except IOError: + return False + return True + + def _handleGotRevision(self, res): + d = defer.maybeDeferred(self.parseGotRevision) + d.addCallback(lambda got_revision: + self.sendStatus({'got_revision': got_revision})) + return d + + def parseGotRevision(self): + """Override this in a subclass. It should return a string that + represents which revision was actually checked out, or a Deferred + that will fire with such a string. If, in a future build, you were to + pass this 'got_revision' string in as the 'revision' component of a + SourceStamp, you should wind up with the same source code as this + checkout just obtained. + + It is probably most useful to scan self.command.stdout for a string + of some sort. Be sure to set keepStdout=True on the VC command that + you run, so that you'll have something available to look at. + + If this information is unavailable, just return None.""" + + return None + + def writeSourcedata(self, res): + open(self.sourcedatafile, "w").write(self.sourcedata) + return res + + def sourcedirIsUpdateable(self): + raise NotImplementedError("this must be implemented in a subclass") + + def doVCUpdate(self): + raise NotImplementedError("this must be implemented in a subclass") + + def doVCFull(self): + raise NotImplementedError("this must be implemented in a subclass") + + def maybeDoVCFallback(self, rc): + if type(rc) is int and rc == 0: + return rc + if self.interrupted: + raise AbandonChain(1) + msg = "update failed, clobbering and trying again" + self.sendStatus({'header': msg + "\n"}) + log.msg(msg) + d = self.doClobber(None, self.srcdir) + d.addCallback(self.doVCFallback2) + return d + + def doVCFallback2(self, res): + msg = "now retrying VC operation" + self.sendStatus({'header': msg + "\n"}) + log.msg(msg) + d = self.doVCFull() + d.addBoth(self.maybeDoVCRetry) + d.addCallback(self._abandonOnFailure) + return d + + def maybeDoVCRetry(self, res): + """We get here somewhere after a VC chain has finished. res could + be:: + + - 0: the operation was successful + - nonzero: the operation failed. retry if possible + - AbandonChain: the operation failed, someone else noticed. retry. + - Failure: some other exception, re-raise + """ + + if isinstance(res, failure.Failure): + if self.interrupted: + return res # don't re-try interrupted builds + res.trap(AbandonChain) + else: + if type(res) is int and res == 0: + return res + if self.interrupted: + raise AbandonChain(1) + # if we get here, we should retry, if possible + if self.retry: + delay, repeats = self.retry + if repeats >= 0: + self.retry = (delay, repeats-1) + msg = ("update failed, trying %d more times after %d seconds" + % (repeats, delay)) + self.sendStatus({'header': msg + "\n"}) + log.msg(msg) + d = defer.Deferred() + self.maybeClobber(d) + d.addCallback(lambda res: self.doVCFull()) + d.addBoth(self.maybeDoVCRetry) + reactor.callLater(delay, d.callback, None) + return d + return res + + def doClobber(self, dummy, dirname): + # TODO: remove the old tree in the background +## workdir = os.path.join(self.builder.basedir, self.workdir) +## deaddir = self.workdir + ".deleting" +## if os.path.isdir(workdir): +## try: +## os.rename(workdir, deaddir) +## # might fail if deaddir already exists: previous deletion +## # hasn't finished yet +## # start the deletion in the background +## # TODO: there was a solaris/NetApp/NFS problem where a +## # process that was still running out of the directory we're +## # trying to delete could prevent the rm-rf from working. I +## # think it stalled the rm, but maybe it just died with +## # permission issues. Try to detect this. +## os.commands("rm -rf %s &" % deaddir) +## except: +## # fall back to sequential delete-then-checkout +## pass + d = os.path.join(self.builder.basedir, dirname) + if runtime.platformType != "posix": + # if we're running on w32, use rmtree instead. It will block, + # but hopefully it won't take too long. + rmdirRecursive(d) + return defer.succeed(0) + command = ["rm", "-rf", d] + c = ShellCommand(self.builder, command, self.builder.basedir, + sendRC=0, timeout=self.timeout, usePTY=False) + + self.command = c + # sendRC=0 means the rm command will send stdout/stderr to the + # master, but not the rc=0 when it finishes. That job is left to + # _sendRC + d = c.start() + d.addCallback(self._abandonOnFailure) + return d + + def doCopy(self, res): + # now copy tree to workdir + fromdir = os.path.join(self.builder.basedir, self.srcdir) + todir = os.path.join(self.builder.basedir, self.workdir) + if runtime.platformType != "posix": + self.sendStatus({'header': "Since we're on a non-POSIX platform, " + "we're not going to try to execute cp in a subprocess, but instead " + "use shutil.copytree(), which will block until it is complete. " + "fromdir: %s, todir: %s\n" % (fromdir, todir)}) + shutil.copytree(fromdir, todir) + return defer.succeed(0) + + if not os.path.exists(os.path.dirname(todir)): + os.makedirs(os.path.dirname(todir)) + if os.path.exists(todir): + # I don't think this happens, but just in case.. + log.msg("cp target '%s' already exists -- cp will not do what you think!" % todir) + + command = ['cp', '-R', '-P', '-p', fromdir, todir] + c = ShellCommand(self.builder, command, self.builder.basedir, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + return d + + def doPatch(self, res): + patchlevel, diff = self.patch + command = [getCommand("patch"), '-p%d' % patchlevel] + dir = os.path.join(self.builder.basedir, self.workdir) + # mark the directory so we don't try to update it later + open(os.path.join(dir, ".buildbot-patched"), "w").write("patched\n") + # now apply the patch + c = ShellCommand(self.builder, command, dir, + sendRC=False, timeout=self.timeout, + initialStdin=diff, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + return d + + +class CVS(SourceBase): + """CVS-specific VC operation. In addition to the arguments handled by + SourceBase, this command reads the following keys: + + ['cvsroot'] (required): the CVSROOT repository string + ['cvsmodule'] (required): the module to be retrieved + ['branch']: a '-r' tag or branch name to use for the checkout/update + ['login']: a string for use as a password to 'cvs login' + ['global_options']: a list of strings to use before the CVS verb + """ + + header = "cvs operation" + + def setup(self, args): + SourceBase.setup(self, args) + self.vcexe = getCommand("cvs") + self.cvsroot = args['cvsroot'] + self.cvsmodule = args['cvsmodule'] + self.global_options = args.get('global_options', []) + self.branch = args.get('branch') + self.login = args.get('login') + self.sourcedata = "%s\n%s\n%s\n" % (self.cvsroot, self.cvsmodule, + self.branch) + + def sourcedirIsUpdateable(self): + if os.path.exists(os.path.join(self.builder.basedir, + self.srcdir, ".buildbot-patched")): + return False + return os.path.isdir(os.path.join(self.builder.basedir, + self.srcdir, "CVS")) + + def start(self): + if self.login is not None: + # need to do a 'cvs login' command first + d = self.builder.basedir + command = ([self.vcexe, '-d', self.cvsroot] + self.global_options + + ['login']) + c = ShellCommand(self.builder, command, d, + sendRC=False, timeout=self.timeout, + initialStdin=self.login+"\n", usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + d.addCallback(self._didLogin) + return d + else: + return self._didLogin(None) + + def _didLogin(self, res): + # now we really start + return SourceBase.start(self) + + def doVCUpdate(self): + d = os.path.join(self.builder.basedir, self.srcdir) + command = [self.vcexe, '-z3'] + self.global_options + ['update', '-dP'] + if self.branch: + command += ['-r', self.branch] + if self.revision: + command += ['-D', self.revision] + c = ShellCommand(self.builder, command, d, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + return c.start() + + def doVCFull(self): + d = self.builder.basedir + if self.mode == "export": + verb = "export" + else: + verb = "checkout" + command = ([self.vcexe, '-d', self.cvsroot, '-z3'] + + self.global_options + + [verb, '-d', self.srcdir]) + if self.branch: + command += ['-r', self.branch] + if self.revision: + command += ['-D', self.revision] + command += [self.cvsmodule] + c = ShellCommand(self.builder, command, d, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + return c.start() + + def parseGotRevision(self): + # CVS does not have any kind of revision stamp to speak of. We return + # the current timestamp as a best-effort guess, but this depends upon + # the local system having a clock that is + # reasonably-well-synchronized with the repository. + return time.strftime("%Y-%m-%d %H:%M:%S +0000", time.gmtime()) + +registerSlaveCommand("cvs", CVS, command_version) + +class SVN(SourceBase): + """Subversion-specific VC operation. In addition to the arguments + handled by SourceBase, this command reads the following keys: + + ['svnurl'] (required): the SVN repository string + ['username'] Username passed to the svn command + ['password'] Password passed to the svn command + """ + + header = "svn operation" + + def setup(self, args): + SourceBase.setup(self, args) + self.vcexe = getCommand("svn") + self.svnurl = args['svnurl'] + self.sourcedata = "%s\n" % self.svnurl + + self.extra_args = [] + if args.has_key('username'): + self.extra_args.extend(["--username", args['username']]) + if args.has_key('password'): + self.extra_args.extend(["--password", Obfuscated(args['password'], "XXXX")]) + + def sourcedirIsUpdateable(self): + if os.path.exists(os.path.join(self.builder.basedir, + self.srcdir, ".buildbot-patched")): + return False + return os.path.isdir(os.path.join(self.builder.basedir, + self.srcdir, ".svn")) + + def doVCUpdate(self): + revision = self.args['revision'] or 'HEAD' + # update: possible for mode in ('copy', 'update') + d = os.path.join(self.builder.basedir, self.srcdir) + command = [self.vcexe, 'update'] + \ + self.extra_args + \ + ['--revision', str(revision), + '--non-interactive', '--no-auth-cache'] + c = ShellCommand(self.builder, command, d, + sendRC=False, timeout=self.timeout, + keepStdout=True, usePTY=False) + self.command = c + return c.start() + + def doVCFull(self): + revision = self.args['revision'] or 'HEAD' + d = self.builder.basedir + if self.mode == "export": + command = [self.vcexe, 'export'] + \ + self.extra_args + \ + ['--revision', str(revision), + '--non-interactive', '--no-auth-cache', + self.svnurl, self.srcdir] + else: + # mode=='clobber', or copy/update on a broken workspace + command = [self.vcexe, 'checkout'] + \ + self.extra_args + \ + ['--revision', str(revision), + '--non-interactive', '--no-auth-cache', + self.svnurl, self.srcdir] + c = ShellCommand(self.builder, command, d, + sendRC=False, timeout=self.timeout, + keepStdout=True, usePTY=False) + self.command = c + return c.start() + + def getSvnVersionCommand(self): + """ + Get the (shell) command used to determine SVN revision number + of checked-out code + + return: list of strings, passable as the command argument to ShellCommand + """ + # svn checkout operations finish with 'Checked out revision 16657.' + # svn update operations finish the line 'At revision 16654.' + # But we don't use those. Instead, run 'svnversion'. + svnversion_command = getCommand("svnversion") + # older versions of 'svnversion' (1.1.4) require the WC_PATH + # argument, newer ones (1.3.1) do not. + return [svnversion_command, "."] + + def parseGotRevision(self): + c = ShellCommand(self.builder, + self.getSvnVersionCommand(), + os.path.join(self.builder.basedir, self.srcdir), + environ=self.env, + sendStdout=False, sendStderr=False, sendRC=False, + keepStdout=True, usePTY=False) + d = c.start() + def _parse(res): + r_raw = c.stdout.strip() + # Extract revision from the version "number" string + r = r_raw.rstrip('MS') + r = r.split(':')[-1] + got_version = None + try: + got_version = int(r) + except ValueError: + msg =("SVN.parseGotRevision unable to parse output " + "of svnversion: '%s'" % r_raw) + log.msg(msg) + self.sendStatus({'header': msg + "\n"}) + return got_version + d.addCallback(_parse) + return d + + +registerSlaveCommand("svn", SVN, command_version) + +class Darcs(SourceBase): + """Darcs-specific VC operation. In addition to the arguments + handled by SourceBase, this command reads the following keys: + + ['repourl'] (required): the Darcs repository string + """ + + header = "darcs operation" + + def setup(self, args): + SourceBase.setup(self, args) + self.vcexe = getCommand("darcs") + self.repourl = args['repourl'] + self.sourcedata = "%s\n" % self.repourl + self.revision = self.args.get('revision') + + def sourcedirIsUpdateable(self): + if os.path.exists(os.path.join(self.builder.basedir, + self.srcdir, ".buildbot-patched")): + return False + if self.revision: + # checking out a specific revision requires a full 'darcs get' + return False + return os.path.isdir(os.path.join(self.builder.basedir, + self.srcdir, "_darcs")) + + def doVCUpdate(self): + assert not self.revision + # update: possible for mode in ('copy', 'update') + d = os.path.join(self.builder.basedir, self.srcdir) + command = [self.vcexe, 'pull', '--all', '--verbose'] + c = ShellCommand(self.builder, command, d, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + return c.start() + + def doVCFull(self): + # checkout or export + d = self.builder.basedir + command = [self.vcexe, 'get', '--verbose', '--partial', + '--repo-name', self.srcdir] + if self.revision: + # write the context to a file + n = os.path.join(self.builder.basedir, ".darcs-context") + f = open(n, "wb") + f.write(self.revision) + f.close() + # tell Darcs to use that context + command.append('--context') + command.append(n) + command.append(self.repourl) + + c = ShellCommand(self.builder, command, d, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + if self.revision: + d.addCallback(self.removeContextFile, n) + return d + + def removeContextFile(self, res, n): + os.unlink(n) + return res + + def parseGotRevision(self): + # we use 'darcs context' to find out what we wound up with + command = [self.vcexe, "changes", "--context"] + c = ShellCommand(self.builder, command, + os.path.join(self.builder.basedir, self.srcdir), + environ=self.env, + sendStdout=False, sendStderr=False, sendRC=False, + keepStdout=True, usePTY=False) + d = c.start() + d.addCallback(lambda res: c.stdout) + return d + +registerSlaveCommand("darcs", Darcs, command_version) + +class Monotone(SourceBase): + """Monotone-specific VC operation. In addition to the arguments handled + by SourceBase, this command reads the following keys: + + ['server_addr'] (required): the address of the server to pull from + ['branch'] (required): the branch the revision is on + ['db_path'] (required): the local database path to use + ['revision'] (required): the revision to check out + ['monotone']: (required): path to monotone executable + """ + + header = "monotone operation" + + def setup(self, args): + SourceBase.setup(self, args) + self.server_addr = args["server_addr"] + self.branch = args["branch"] + self.db_path = args["db_path"] + self.revision = args["revision"] + self.monotone = args["monotone"] + self._made_fulls = False + self._pull_timeout = args["timeout"] + + def _makefulls(self): + if not self._made_fulls: + basedir = self.builder.basedir + self.full_db_path = os.path.join(basedir, self.db_path) + self.full_srcdir = os.path.join(basedir, self.srcdir) + self._made_fulls = True + + def sourcedirIsUpdateable(self): + self._makefulls() + if os.path.exists(os.path.join(self.full_srcdir, + ".buildbot_patched")): + return False + return (os.path.isfile(self.full_db_path) + and os.path.isdir(os.path.join(self.full_srcdir, "MT"))) + + def doVCUpdate(self): + return self._withFreshDb(self._doUpdate) + + def _doUpdate(self): + # update: possible for mode in ('copy', 'update') + command = [self.monotone, "update", + "-r", self.revision, + "-b", self.branch] + c = ShellCommand(self.builder, command, self.full_srcdir, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + return c.start() + + def doVCFull(self): + return self._withFreshDb(self._doFull) + + def _doFull(self): + command = [self.monotone, "--db=" + self.full_db_path, + "checkout", + "-r", self.revision, + "-b", self.branch, + self.full_srcdir] + c = ShellCommand(self.builder, command, self.builder.basedir, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + return c.start() + + def _withFreshDb(self, callback): + self._makefulls() + # first ensure the db exists and is usable + if os.path.isfile(self.full_db_path): + # already exists, so run 'db migrate' in case monotone has been + # upgraded under us + command = [self.monotone, "db", "migrate", + "--db=" + self.full_db_path] + else: + # We'll be doing an initial pull, so up the timeout to 3 hours to + # make sure it will have time to complete. + self._pull_timeout = max(self._pull_timeout, 3 * 60 * 60) + self.sendStatus({"header": "creating database %s\n" + % (self.full_db_path,)}) + command = [self.monotone, "db", "init", + "--db=" + self.full_db_path] + c = ShellCommand(self.builder, command, self.builder.basedir, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + d.addCallback(self._didDbInit) + d.addCallback(self._didPull, callback) + return d + + def _didDbInit(self, res): + command = [self.monotone, "--db=" + self.full_db_path, + "pull", "--ticker=dot", self.server_addr, self.branch] + c = ShellCommand(self.builder, command, self.builder.basedir, + sendRC=False, timeout=self._pull_timeout, usePTY=False) + self.sendStatus({"header": "pulling %s from %s\n" + % (self.branch, self.server_addr)}) + self.command = c + return c.start() + + def _didPull(self, res, callback): + return callback() + +registerSlaveCommand("monotone", Monotone, command_version) + + +class Git(SourceBase): + """Git specific VC operation. In addition to the arguments + handled by SourceBase, this command reads the following keys: + + ['repourl'] (required): the upstream GIT repository string + ['branch'] (optional): which version (i.e. branch or tag) to + retrieve. Default: "master". + """ + + header = "git operation" + + def setup(self, args): + SourceBase.setup(self, args) + self.repourl = args['repourl'] + self.branch = args.get('branch') + if not self.branch: + self.branch = "master" + self.sourcedata = "%s %s\n" % (self.repourl, self.branch) + + def _fullSrcdir(self): + return os.path.join(self.builder.basedir, self.srcdir) + + def _commitSpec(self): + if self.revision: + return self.revision + return self.branch + + def sourcedirIsUpdateable(self): + if os.path.exists(os.path.join(self._fullSrcdir(), + ".buildbot-patched")): + return False + return os.path.isdir(os.path.join(self._fullSrcdir(), ".git")) + + def readSourcedata(self): + return open(self.sourcedatafile, "r").read() + + # If the repourl matches the sourcedata file, then + # we can say that the sourcedata matches. We can + # ignore branch changes, since Git can work with + # many branches fetched, and we deal with it properly + # in doVCUpdate. + def sourcedataMatches(self): + try: + olddata = self.readSourcedata() + if not olddata.startswith(self.repourl+' '): + return False + except IOError: + return False + return True + + def _didFetch(self, res): + if self.revision: + head = self.revision + else: + head = 'FETCH_HEAD' + + command = ['git', 'reset', '--hard', head] + c = ShellCommand(self.builder, command, self._fullSrcdir(), + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + return c.start() + + # Update first runs "git clean", removing local changes, + # if the branch to be checked out has changed. This, combined + # with the later "git reset" equates clobbering the repo, + # but it's much more efficient. + def doVCUpdate(self): + try: + # Check to see if our branch has changed + diffbranch = self.sourcedata != self.readSourcedata() + except IOError: + diffbranch = False + if diffbranch: + command = ['git', 'clean', '-f', '-d'] + c = ShellCommand(self.builder, command, self._fullSrcdir(), + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + d.addCallback(self._didClean) + return d + return self._didClean(None) + + def _didClean(self, dummy): + command = ['git', 'fetch', '-t', self.repourl, self.branch] + self.sendStatus({"header": "fetching branch %s from %s\n" + % (self.branch, self.repourl)}) + c = ShellCommand(self.builder, command, self._fullSrcdir(), + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + d.addCallback(self._didFetch) + return d + + def _didInit(self, res): + return self.doVCUpdate() + + def doVCFull(self): + os.mkdir(self._fullSrcdir()) + c = ShellCommand(self.builder, ['git', 'init'], self._fullSrcdir(), + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + d.addCallback(self._didInit) + return d + + def parseGotRevision(self): + command = ['git', 'rev-parse', 'HEAD'] + c = ShellCommand(self.builder, command, self._fullSrcdir(), + sendRC=False, keepStdout=True, usePTY=False) + d = c.start() + def _parse(res): + hash = c.stdout.strip() + if len(hash) != 40: + return None + return hash + d.addCallback(_parse) + return d + +registerSlaveCommand("git", Git, command_version) + +class Arch(SourceBase): + """Arch-specific (tla-specific) VC operation. In addition to the + arguments handled by SourceBase, this command reads the following keys: + + ['url'] (required): the repository string + ['version'] (required): which version (i.e. branch) to retrieve + ['revision'] (optional): the 'patch-NN' argument to check out + ['archive']: the archive name to use. If None, use the archive's default + ['build-config']: if present, give to 'tla build-config' after checkout + """ + + header = "arch operation" + buildconfig = None + + def setup(self, args): + SourceBase.setup(self, args) + self.vcexe = getCommand("tla") + self.archive = args.get('archive') + self.url = args['url'] + self.version = args['version'] + self.revision = args.get('revision') + self.buildconfig = args.get('build-config') + self.sourcedata = "%s\n%s\n%s\n" % (self.url, self.version, + self.buildconfig) + + def sourcedirIsUpdateable(self): + if self.revision: + # Arch cannot roll a directory backwards, so if they ask for a + # specific revision, clobber the directory. Technically this + # could be limited to the cases where the requested revision is + # later than our current one, but it's too hard to extract the + # current revision from the tree. + return False + if os.path.exists(os.path.join(self.builder.basedir, + self.srcdir, ".buildbot-patched")): + return False + return os.path.isdir(os.path.join(self.builder.basedir, + self.srcdir, "{arch}")) + + def doVCUpdate(self): + # update: possible for mode in ('copy', 'update') + d = os.path.join(self.builder.basedir, self.srcdir) + command = [self.vcexe, 'replay'] + if self.revision: + command.append(self.revision) + c = ShellCommand(self.builder, command, d, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + return c.start() + + def doVCFull(self): + # to do a checkout, we must first "register" the archive by giving + # the URL to tla, which will go to the repository at that URL and + # figure out the archive name. tla will tell you the archive name + # when it is done, and all further actions must refer to this name. + + command = [self.vcexe, 'register-archive', '--force', self.url] + c = ShellCommand(self.builder, command, self.builder.basedir, + sendRC=False, keepStdout=True, + timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + d.addCallback(self._didRegister, c) + return d + + def _didRegister(self, res, c): + # find out what tla thinks the archive name is. If the user told us + # to use something specific, make sure it matches. + r = re.search(r'Registering archive: (\S+)\s*$', c.stdout) + if r: + msg = "tla reports archive name is '%s'" % r.group(1) + log.msg(msg) + self.builder.sendUpdate({'header': msg+"\n"}) + if self.archive and r.group(1) != self.archive: + msg = (" mismatch, we wanted an archive named '%s'" + % self.archive) + log.msg(msg) + self.builder.sendUpdate({'header': msg+"\n"}) + raise AbandonChain(-1) + self.archive = r.group(1) + assert self.archive, "need archive name to continue" + return self._doGet() + + def _doGet(self): + ver = self.version + if self.revision: + ver += "--%s" % self.revision + command = [self.vcexe, 'get', '--archive', self.archive, + '--no-pristine', + ver, self.srcdir] + c = ShellCommand(self.builder, command, self.builder.basedir, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + if self.buildconfig: + d.addCallback(self._didGet) + return d + + def _didGet(self, res): + d = os.path.join(self.builder.basedir, self.srcdir) + command = [self.vcexe, 'build-config', self.buildconfig] + c = ShellCommand(self.builder, command, d, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + return d + + def parseGotRevision(self): + # using code from tryclient.TlaExtractor + # 'tla logs --full' gives us ARCHIVE/BRANCH--REVISION + # 'tla logs' gives us REVISION + command = [self.vcexe, "logs", "--full", "--reverse"] + c = ShellCommand(self.builder, command, + os.path.join(self.builder.basedir, self.srcdir), + environ=self.env, + sendStdout=False, sendStderr=False, sendRC=False, + keepStdout=True, usePTY=False) + d = c.start() + def _parse(res): + tid = c.stdout.split("\n")[0].strip() + slash = tid.index("/") + dd = tid.rindex("--") + #branch = tid[slash+1:dd] + baserev = tid[dd+2:] + return baserev + d.addCallback(_parse) + return d + +registerSlaveCommand("arch", Arch, command_version) + +class Bazaar(Arch): + """Bazaar (/usr/bin/baz) is an alternative client for Arch repositories. + It is mostly option-compatible, but archive registration is different + enough to warrant a separate Command. + + ['archive'] (required): the name of the archive being used + """ + + def setup(self, args): + Arch.setup(self, args) + self.vcexe = getCommand("baz") + # baz doesn't emit the repository name after registration (and + # grepping through the output of 'baz archives' is too hard), so we + # require that the buildmaster configuration to provide both the + # archive name and the URL. + self.archive = args['archive'] # required for Baz + self.sourcedata = "%s\n%s\n%s\n" % (self.url, self.version, + self.buildconfig) + + # in _didRegister, the regexp won't match, so we'll stick with the name + # in self.archive + + def _doGet(self): + # baz prefers ARCHIVE/VERSION. This will work even if + # my-default-archive is not set. + ver = self.archive + "/" + self.version + if self.revision: + ver += "--%s" % self.revision + command = [self.vcexe, 'get', '--no-pristine', + ver, self.srcdir] + c = ShellCommand(self.builder, command, self.builder.basedir, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + if self.buildconfig: + d.addCallback(self._didGet) + return d + + def parseGotRevision(self): + # using code from tryclient.BazExtractor + command = [self.vcexe, "tree-id"] + c = ShellCommand(self.builder, command, + os.path.join(self.builder.basedir, self.srcdir), + environ=self.env, + sendStdout=False, sendStderr=False, sendRC=False, + keepStdout=True, usePTY=False) + d = c.start() + def _parse(res): + tid = c.stdout.strip() + slash = tid.index("/") + dd = tid.rindex("--") + #branch = tid[slash+1:dd] + baserev = tid[dd+2:] + return baserev + d.addCallback(_parse) + return d + +registerSlaveCommand("bazaar", Bazaar, command_version) + + +class Bzr(SourceBase): + """bzr-specific VC operation. In addition to the arguments + handled by SourceBase, this command reads the following keys: + + ['repourl'] (required): the Bzr repository string + """ + + header = "bzr operation" + + def setup(self, args): + SourceBase.setup(self, args) + self.vcexe = getCommand("bzr") + self.repourl = args['repourl'] + self.sourcedata = "%s\n" % self.repourl + self.revision = self.args.get('revision') + + def sourcedirIsUpdateable(self): + if os.path.exists(os.path.join(self.builder.basedir, + self.srcdir, ".buildbot-patched")): + return False + if self.revision: + # checking out a specific revision requires a full 'bzr checkout' + return False + return os.path.isdir(os.path.join(self.builder.basedir, + self.srcdir, ".bzr")) + + def doVCUpdate(self): + assert not self.revision + # update: possible for mode in ('copy', 'update') + srcdir = os.path.join(self.builder.basedir, self.srcdir) + command = [self.vcexe, 'update'] + c = ShellCommand(self.builder, command, srcdir, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + return c.start() + + def doVCFull(self): + # checkout or export + d = self.builder.basedir + if self.mode == "export": + # exporting in bzr requires a separate directory + return self.doVCExport() + # originally I added --lightweight here, but then 'bzr revno' is + # wrong. The revno reported in 'bzr version-info' is correct, + # however. Maybe this is a bzr bug? + # + # In addition, you cannot perform a 'bzr update' on a repo pulled + # from an HTTP repository that used 'bzr checkout --lightweight'. You + # get a "ERROR: Cannot lock: transport is read only" when you try. + # + # So I won't bother using --lightweight for now. + + command = [self.vcexe, 'checkout'] + if self.revision: + command.append('--revision') + command.append(str(self.revision)) + command.append(self.repourl) + command.append(self.srcdir) + + c = ShellCommand(self.builder, command, d, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + return d + + def doVCExport(self): + tmpdir = os.path.join(self.builder.basedir, "export-temp") + srcdir = os.path.join(self.builder.basedir, self.srcdir) + command = [self.vcexe, 'checkout', '--lightweight'] + if self.revision: + command.append('--revision') + command.append(str(self.revision)) + command.append(self.repourl) + command.append(tmpdir) + c = ShellCommand(self.builder, command, self.builder.basedir, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + d = c.start() + def _export(res): + command = [self.vcexe, 'export', srcdir] + c = ShellCommand(self.builder, command, tmpdir, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + return c.start() + d.addCallback(_export) + return d + + def get_revision_number(self, out): + # it feels like 'bzr revno' sometimes gives different results than + # the 'revno:' line from 'bzr version-info', and the one from + # version-info is more likely to be correct. + for line in out.split("\n"): + colon = line.find(":") + if colon != -1: + key, value = line[:colon], line[colon+2:] + if key == "revno": + return int(value) + raise ValueError("unable to find revno: in bzr output: '%s'" % out) + + def parseGotRevision(self): + command = [self.vcexe, "version-info"] + c = ShellCommand(self.builder, command, + os.path.join(self.builder.basedir, self.srcdir), + environ=self.env, + sendStdout=False, sendStderr=False, sendRC=False, + keepStdout=True, usePTY=False) + d = c.start() + def _parse(res): + try: + return self.get_revision_number(c.stdout) + except ValueError: + msg =("Bzr.parseGotRevision unable to parse output " + "of bzr version-info: '%s'" % c.stdout.strip()) + log.msg(msg) + self.sendStatus({'header': msg + "\n"}) + return None + d.addCallback(_parse) + return d + +registerSlaveCommand("bzr", Bzr, command_version) + +class Mercurial(SourceBase): + """Mercurial specific VC operation. In addition to the arguments + handled by SourceBase, this command reads the following keys: + + ['repourl'] (required): the Cogito repository string + """ + + header = "mercurial operation" + + def setup(self, args): + SourceBase.setup(self, args) + self.vcexe = getCommand("hg") + self.repourl = args['repourl'] + self.sourcedata = "%s\n" % self.repourl + self.stdout = "" + self.stderr = "" + + def sourcedirIsUpdateable(self): + if os.path.exists(os.path.join(self.builder.basedir, + self.srcdir, ".buildbot-patched")): + return False + # like Darcs, to check out a specific (old) revision, we have to do a + # full checkout. TODO: I think 'hg pull' plus 'hg update' might work + if self.revision: + return False + return os.path.isdir(os.path.join(self.builder.basedir, + self.srcdir, ".hg")) + + def doVCUpdate(self): + d = os.path.join(self.builder.basedir, self.srcdir) + command = [self.vcexe, 'pull', '--verbose', self.repourl] + c = ShellCommand(self.builder, command, d, + sendRC=False, timeout=self.timeout, + keepStdout=True, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._handleEmptyUpdate) + d.addCallback(self._update) + return d + + def _handleEmptyUpdate(self, res): + if type(res) is int and res == 1: + if self.command.stdout.find("no changes found") != -1: + # 'hg pull', when it doesn't have anything to do, exits with + # rc=1, and there appears to be no way to shut this off. It + # emits a distinctive message to stdout, though. So catch + # this and pretend that it completed successfully. + return 0 + return res + + def doVCFull(self): + d = os.path.join(self.builder.basedir, self.srcdir) + command = [self.vcexe, 'init', d] + c = ShellCommand(self.builder, command, self.builder.basedir, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + cmd1 = c.start() + + def _vcupdate(res): + return self.doVCUpdate() + + cmd1.addCallback(_vcupdate) + return cmd1 + + def _update(self, res): + if res != 0: + return res + + # compare current branch to update + self.update_branch = self.args.get('branch', 'default') + + d = os.path.join(self.builder.basedir, self.srcdir) + parentscmd = [self.vcexe, 'identify', '--num', '--branch'] + cmd = ShellCommand(self.builder, parentscmd, d, + sendStdout=False, sendStderr=False, + keepStdout=True, keepStderr=True, usePTY=False) + + def _parse(res): + if res != 0: + msg = "'hg identify' failed: %s\n%s" % (cmd.stdout, cmd.stderr) + self.sendStatus({'header': msg + "\n"}) + log.msg(msg) + return res + + log.msg('Output: %s' % cmd.stdout) + + match = re.search(r'^(.+) (.+)$', cmd.stdout) + assert match + + rev = match.group(1) + current_branch = match.group(2) + + if rev == '-1': + msg = "Fresh hg repo, don't worry about branch" + log.msg(msg) + + elif self.update_branch != current_branch: + msg = "Working dir is on branch '%s' and build needs '%s'. Clobbering." % (current_branch, self.update_branch) + self.sendStatus({'header': msg + "\n"}) + log.msg(msg) + + def _vcfull(res): + return self.doVCFull() + + d = self.doClobber(None, self.srcdir) + d.addCallback(_vcfull) + return d + + else: + msg = "Working dir on same branch as build (%s)." % (current_branch) + log.msg(msg) + + return 0 + + c = cmd.start() + c.addCallback(_parse) + c.addCallback(self._update2) + return c + + def _update2(self, res): + d = os.path.join(self.builder.basedir, self.srcdir) + + updatecmd=[self.vcexe, 'update', '--clean', '--repository', d] + if self.args.get('revision'): + updatecmd.extend(['--rev', self.args['revision']]) + else: + updatecmd.extend(['--rev', self.args.get('branch', 'default')]) + self.command = ShellCommand(self.builder, updatecmd, + self.builder.basedir, sendRC=False, + timeout=self.timeout, usePTY=False) + return self.command.start() + + def parseGotRevision(self): + # we use 'hg identify' to find out what we wound up with + command = [self.vcexe, "identify"] + c = ShellCommand(self.builder, command, + os.path.join(self.builder.basedir, self.srcdir), + environ=self.env, + sendStdout=False, sendStderr=False, sendRC=False, + keepStdout=True, usePTY=False) + d = c.start() + def _parse(res): + m = re.search(r'^(\w+)', c.stdout) + return m.group(1) + d.addCallback(_parse) + return d + +registerSlaveCommand("hg", Mercurial, command_version) + + +class P4Base(SourceBase): + """Base class for P4 source-updaters + + ['p4port'] (required): host:port for server to access + ['p4user'] (optional): user to use for access + ['p4passwd'] (optional): passwd to try for the user + ['p4client'] (optional): client spec to use + """ + def setup(self, args): + SourceBase.setup(self, args) + self.p4port = args['p4port'] + self.p4client = args['p4client'] + self.p4user = args['p4user'] + self.p4passwd = args['p4passwd'] + + def parseGotRevision(self): + # Executes a p4 command that will give us the latest changelist number + # of any file under the current (or default) client: + command = ['p4'] + if self.p4port: + command.extend(['-p', self.p4port]) + if self.p4user: + command.extend(['-u', self.p4user]) + if self.p4passwd: + command.extend(['-P', self.p4passwd]) + if self.p4client: + command.extend(['-c', self.p4client]) + command.extend(['changes', '-m', '1', '#have']) + c = ShellCommand(self.builder, command, self.builder.basedir, + environ=self.env, timeout=self.timeout, + sendStdout=True, sendStderr=False, sendRC=False, + keepStdout=True, usePTY=False) + self.command = c + d = c.start() + + def _parse(res): + # 'p4 -c clien-name change -m 1 "#have"' will produce an output like: + # "Change 28147 on 2008/04/07 by p4user@hostname..." + # The number after "Change" is the one we want. + m = re.match('Change\s+(\d+)\s+', c.stdout) + if m: + return m.group(1) + return None + d.addCallback(_parse) + return d + + +class P4(P4Base): + """A P4 source-updater. + + ['p4port'] (required): host:port for server to access + ['p4user'] (optional): user to use for access + ['p4passwd'] (optional): passwd to try for the user + ['p4client'] (optional): client spec to use + ['p4extra_views'] (optional): additional client views to use + """ + + header = "p4" + + def setup(self, args): + P4Base.setup(self, args) + self.p4base = args['p4base'] + self.p4extra_views = args['p4extra_views'] + self.p4mode = args['mode'] + self.p4branch = args['branch'] + + self.sourcedata = str([ + # Perforce server. + self.p4port, + + # Client spec. + self.p4client, + + # Depot side of view spec. + self.p4base, + self.p4branch, + self.p4extra_views, + + # Local side of view spec (srcdir is made from these). + self.builder.basedir, + self.mode, + self.workdir + ]) + + + def sourcedirIsUpdateable(self): + if os.path.exists(os.path.join(self.builder.basedir, + self.srcdir, ".buildbot-patched")): + return False + # We assume our client spec is still around. + # We just say we aren't updateable if the dir doesn't exist so we + # don't get ENOENT checking the sourcedata. + return os.path.isdir(os.path.join(self.builder.basedir, + self.srcdir)) + + def doVCUpdate(self): + return self._doP4Sync(force=False) + + def _doP4Sync(self, force): + command = ['p4'] + + if self.p4port: + command.extend(['-p', self.p4port]) + if self.p4user: + command.extend(['-u', self.p4user]) + if self.p4passwd: + command.extend(['-P', self.p4passwd]) + if self.p4client: + command.extend(['-c', self.p4client]) + command.extend(['sync']) + if force: + command.extend(['-f']) + if self.revision: + command.extend(['@' + str(self.revision)]) + env = {} + c = ShellCommand(self.builder, command, self.builder.basedir, + environ=env, sendRC=False, timeout=self.timeout, + keepStdout=True, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + return d + + + def doVCFull(self): + env = {} + command = ['p4'] + client_spec = '' + client_spec += "Client: %s\n\n" % self.p4client + client_spec += "Owner: %s\n\n" % self.p4user + client_spec += "Description:\n\tCreated by %s\n\n" % self.p4user + client_spec += "Root:\t%s\n\n" % self.builder.basedir + client_spec += "Options:\tallwrite rmdir\n\n" + client_spec += "LineEnd:\tlocal\n\n" + + # Setup a view + client_spec += "View:\n\t%s" % (self.p4base) + if self.p4branch: + client_spec += "%s/" % (self.p4branch) + client_spec += "... //%s/%s/...\n" % (self.p4client, self.srcdir) + if self.p4extra_views: + for k, v in self.p4extra_views: + client_spec += "\t%s/... //%s/%s%s/...\n" % (k, self.p4client, + self.srcdir, v) + if self.p4port: + command.extend(['-p', self.p4port]) + if self.p4user: + command.extend(['-u', self.p4user]) + if self.p4passwd: + command.extend(['-P', self.p4passwd]) + command.extend(['client', '-i']) + log.msg(client_spec) + c = ShellCommand(self.builder, command, self.builder.basedir, + environ=env, sendRC=False, timeout=self.timeout, + initialStdin=client_spec, usePTY=False) + self.command = c + d = c.start() + d.addCallback(self._abandonOnFailure) + d.addCallback(lambda _: self._doP4Sync(force=True)) + return d + +registerSlaveCommand("p4", P4, command_version) + + +class P4Sync(P4Base): + """A partial P4 source-updater. Requires manual setup of a per-slave P4 + environment. The only thing which comes from the master is P4PORT. + 'mode' is required to be 'copy'. + + ['p4port'] (required): host:port for server to access + ['p4user'] (optional): user to use for access + ['p4passwd'] (optional): passwd to try for the user + ['p4client'] (optional): client spec to use + """ + + header = "p4 sync" + + def setup(self, args): + P4Base.setup(self, args) + self.vcexe = getCommand("p4") + + def sourcedirIsUpdateable(self): + return True + + def _doVC(self, force): + d = os.path.join(self.builder.basedir, self.srcdir) + command = [self.vcexe] + if self.p4port: + command.extend(['-p', self.p4port]) + if self.p4user: + command.extend(['-u', self.p4user]) + if self.p4passwd: + command.extend(['-P', self.p4passwd]) + if self.p4client: + command.extend(['-c', self.p4client]) + command.extend(['sync']) + if force: + command.extend(['-f']) + if self.revision: + command.extend(['@' + self.revision]) + env = {} + c = ShellCommand(self.builder, command, d, environ=env, + sendRC=False, timeout=self.timeout, usePTY=False) + self.command = c + return c.start() + + def doVCUpdate(self): + return self._doVC(force=False) + + def doVCFull(self): + return self._doVC(force=True) + +registerSlaveCommand("p4sync", P4Sync, command_version) |