|
# This file is part of Buildbot. Buildbot is free software: you can # redistribute it and/or modify it under the terms of the GNU General Public # License as published by the Free Software Foundation, version 2. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more # details. # # You should have received a copy of the GNU General Public License along with # this program; if not, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Copyright Buildbot Team Members
Support for running 'shell commands' """
# attempt to quote cmd_list such that a shell will properly re-interpret # it. The pipes module is only available on UNIX, and Windows "shell" # quoting is indescribably convoluted - so much so that it's not clear it's # reversible. Also, the quote function is undocumented (although it looks # like it will be documentd soon: http://bugs.python.org/issue9723). # Finally, it has a nasty bug in some versions where an empty string is not # quoted. # # So: # - use pipes.quote on UNIX, handling '' as a special case # - use Python's repr() on Windows, as a best effort return " ".join([ `e` for e in cmd_list ]) else: return '""'
# 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.
# follow the file, only sending back lines # added since we started watching
# every 2 seconds we check on the file again
self.poller.start(self.POLL_INTERVAL).addErrback(self._cleanupPoll)
log.err(err, msg="Polling error") self.poller = None
self.poll() if self.poller is not None: self.poller.stop() if self.started: self.f.close()
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") # if we only want new lines, seek to # where we stat'd so we only find new # lines if self.follow: self.f.seek(s[2], 0) 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)
"""Simple subclass of Process to also make the spawned process a process group leader, so we can kill all members of the process group."""
Process._setupChild(self, *args, **kwargs)
# this will cause the child to be the leader of its own process group; # it's also spelled setpgrp() on BSD, but this spelling seems to work # everywhere os.setpgid(0, 0)
assert not self.connected self.pending_stdin = data
if self.debug: log.msg("RunProcessPP.connectionMade")
if self.debug: log.msg(" recording pid %d as subprocess pgid" % (self.transport.pid,))
if self.debug: log.msg(" writing to stdin") self.transport.write(self.pending_stdin) if self.debug: log.msg(" closing stdin")
if self.debug: log.msg("RunProcessPP.outReceived")
if self.debug: log.msg("RunProcessPP.errReceived")
if self.debug: log.msg("RunProcessPP.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
# sometimes, even when we kill a process, GetExitCodeProcess will still return # a zero exit status. So we force it. See # http://stackoverflow.com/questions/2061735/42-passed-to-terminateprocess-sometimes-getexitcodeprocess-returns-0 # windows returns '1' even for signalled failures, while POSIX returns -1 rc = 1 else:
""" This is a helper class, used by slave commands to run programs in a child shell. """
# Don't send any data until at least BUFFER_SIZE bytes have been collected # or BUFFER_TIMEOUT elapsed
# For sending elapsed time:
# For scheduling future events
# 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.
workdir, environ=None, sendStdout=True, sendStderr=True, sendRC=True, timeout=None, maxTime=None, initialStdin=None, keepStdout=False, keepStderr=False, logEnviron=True, logfiles={}, usePTY="slave-config", useProcGroup=True, user=None): """
@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.
@param useProcGroup: (default True) use a process group for non-PTY process invocations """
# We need to take unicode commands and arguments and encode them using # the appropriate encoding for the slave. This is mostly platform # specific, but can be overridden in the slave's buildbot.tac file. # # Encoding the command line here ensures that the called executables # receive arguments as bytestrings encoded with an appropriate # platform-specific encoding. It also plays nicely with twisted's # spawnProcess which checks that arguments are regular strings or # unicode strings that can be encoded as ascii (which generates a # warning).
# 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 it's not a string, treat it as a sequence to be # turned in to a string.
# do substitution on variable values matching pattern: ${name} # setting a key to None will delete it from the slave environment "lists; key '%s' is incorrect" % (key,))
else: # not environ
else:
# 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 self.usePTY and usePTY != "slave-config": self.sendStatus({'header': "WARNING: disabling usePTY for this command"}) self.usePTY = False
# use an explicit process group on POSIX, noting that usePTY always implies # a process group. useProcGroup = False
filename = filevalue follow = False
# check for a dictionary of options # filename is required, others are optional if type(filevalue) == dict: filename = filevalue['filename'] follow = filevalue.get('follow', False)
w = LogFileWatcher(self, name, os.path.join(self.workdir, filename), follow=follow) self.logFileWatchers.append(w)
raise RuntimeError( "Cannot use 'user' parameter on this platform")
def __repr__(self): return "<%s '%s'>" % (self.__class__.__name__, self.fake_command)
# return a Deferred which fires (with the exit code) when the command # completes # pretend it was a shell error
# ensure workdir exists os.makedirs(self.workdir) self._addToBuffers('header', "command '%s' in dir %s" % \ (self.fake_command, self.workdir)) self._addToBuffers('header', "(not really)\n") self.finished(None, 0) return
if runtime.platformType == 'win32': argv = os.environ['COMSPEC'].split() # allow %COMSPEC% to have args if '/c' not in argv: argv += ['/c'] argv += [self.command] self.using_comspec = True else: # for posix, use /bin/sh. for other non-posix, well, doesn't # hurt to try else: # On windows, CreateProcess requires an absolute path to the executable. # When we call spawnProcess below, we pass argv[0] as the executable. # So, for .exe's that we have absolute paths to, we can call directly # Otherwise, we should run under COMSPEC (usually cmd.exe) to # handle path searching, etc. (self.command[0].lower().endswith(".exe") and os.path.isabs(self.command[0])): argv = os.environ['COMSPEC'].split() # allow %COMSPEC% to have args if '/c' not in argv: argv += ['/c'] argv += list(self.command) self.using_comspec = True else: # Attempt to format this for use by a shell, although the process isn't perfect
# If requested, wrap the call in 'sudo' to run the command as a # different user.
# $PWD usually indicates the current directory; spawnProcess may not # update this value, though, so we set it explicitly here. This causes # weird problems (bug #456) on msys, though..
# self.stdin is handled in RunProcessPP.connectionMade
# then comes the secondary information unit = "sec" else: unit = "sec" else:
# then the obfuscated command array for resolving unambiguity
# then the environment, since it sometimes causes problems
msg = " writing %d bytes to stdin" % len(self.initialStdin) log.msg(" " + msg) self._addToBuffers('header', msg+"\n")
# put data into stdin and close it, if necessary. This will be # buffered until connectionMade is called self.pp.setStdin(self.initialStdin)
# start the process
self.pp, argv[0], argv, self.environ, self.workdir, usePTY=self.usePTY)
# set up timeouts
w.start()
path=None, uid=None, gid=None, usePTY=False, childFDs=None): """private implementation of reactor.spawnProcess, to allow use of L{ProcGroupProcess}"""
# use the ProcGroupProcess class, if available processProtocol, uid, gid, childFDs)
# fall back return self._spawnAsBatch(processProtocol, executable, args, env, path, usePTY=usePTY) else: path, usePTY=usePTY)
path, usePTY): """A cheat that routes around the impedance mismatch between twisted and cmd.exe with respect to escaping quotes"""
tf = NamedTemporaryFile(dir='.',suffix=".bat",delete=False) #echo off hides this cheat from the log files. tf.write( "@echo off\n" ) if type(self.command) in types.StringTypes: tf.write( self.command ) else: def maybe_escape_pipes(arg): if arg != '|': return arg.replace('|','^|') else: return '|' cmd = [maybe_escape_pipes(arg) for arg in self.command] tf.write( quoteArguments(cmd) ) tf.close()
argv = os.environ['COMSPEC'].split() # allow %COMSPEC% to have args if '/c' not in argv: argv += ['/c'] argv += [tf.name]
def unlink_temp(result): os.unlink(tf.name) return result self.deferred.addBoth(unlink_temp)
return reactor.spawnProcess(processProtocol, executable, argv, env, path, usePTY=usePTY)
""" limit the chunks that we send over PB to 128k, since it has a hardwired string-size limit of 640k. """
""" Take msg, which is a dictionary of lists of output chunks, and concatentate all the chunks into a single string """ retval['log'] = (log[1], data) else:
""" Collapse and send msg to the master """ return
""" Send all the content in our buffers. """ # Grab the next bits from the buffer
# If this log is different than the last one, then we have to send # out the message so far. This is because the message is # transferred as a dictionary, which makes the ordering of keys # unspecified, and makes it impossible to interleave data from # different logs. A future enhancement could be to change the # master to support a list of (logname, data) tuples instead of a # dictionary. # On our first pass through this loop lastlog is None
# Chunkify the log data to make sure we're not sending more than # CHUNK_LIMIT at a time # We've gone beyond the chunk limit, so send out our # message. At worst this results in a message slightly # larger than (2*CHUNK_LIMIT)-1
""" Add data to the buffer for logname Start a timer to send the buffers if BUFFER_TIMEOUT elapses. If adding data causes the buffer size to grow beyond BUFFER_SIZE, then the buffers will be sent. """
self.timer.reset(self.timeout)
self._addToBuffers( ('log', name), data)
if self.timer: self.timer.reset(self.timeout)
# this will send the final updates w.stop() {'header': "process killed by signal %d\n" % sig}) self.maxTimer.cancel() self.maxTimer = None self.buftimer.cancel() self.buftimer = None else: log.msg("Hey, command %s finished twice" % self)
self._sendBuffers() log.msg("RunProcess.failed: command failed: %s" % (why,)) if self.timer: self.timer.cancel() self.timer = None if self.maxTimer: self.maxTimer.cancel() self.maxTimer = None if self.buftimer: self.buftimer.cancel() self.buftimer = None d = self.deferred self.deferred = None if d: d.errback(why) else: log.msg("Hey, command %s finished twice" % self)
# This may be called by the timeout, or when the user has decided to # abort this build. self.timer.cancel() self.timer = None self.maxTimer.cancel() self.maxTimer = None self.buftimer.cancel() self.buftimer = None
# let the PP know that we are killing it, so that it can ensure that # the exit status comes out right
# keep track of whether we believe we've successfully killed something
# try signalling the process group
log.msg("signal module is missing SIG%s" % self.interruptSignal) log.msg("os module is missing the 'kill' function") log.msg("self.process has no pgid") else: (self.process.pgid,)) (sys.exc_info()[1],)) # probably no-such-process, maybe because there is no process # group pass
if self.interruptSignal == None: log.msg("self.interruptSignal==None, only pretending to kill child") elif self.process.pid is not None: log.msg("using TASKKILL /F PID /T to kill pid %s" % self.process.pid) subprocess.check_call("TASKKILL /F /PID %s /T" % self.process.pid) log.msg("taskkill'd pid %s" % self.process.pid) hit = 1
# try signalling the process itself (works on Windows too, sorta) log.err("from process.signalProcess:") # could be no-such-process, because they finished very recently pass # the process has already exited, and likely finished() has # been called already or will be called shortly pass
# we only do this under posix because the win32eventreactor # blocks here until the process has terminated, while closing # stderr. This is weird.
# finished ought to be called momentarily. Just in case it doesn't, # set a timer which will abandon the command. self.doBackupTimeout)
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(RuntimeError("SIGKILL failed to kill process")) |