Package buildslave :: Package commands :: Module base
[frames] | no frames]

Source Code for Module buildslave.commands.base

  1   
  2  import os, signal, types, re, traceback 
  3  from stat import ST_CTIME, ST_MTIME, ST_SIZE 
  4  import os 
  5  import sys 
  6  import shutil 
  7   
  8  from zope.interface import implements 
  9  from twisted.internet import reactor, defer, task 
 10  from twisted.python import log, failure, runtime 
 11   
 12  from buildslave.interfaces import ISlaveCommand 
 13  from buildslave import runprocess 
 14  from buildslave.exceptions import AbandonChain 
 15  from buildslave.commands import utils 
 16   
 17  # this used to be a CVS $-style "Revision" auto-updated keyword, but since I 
 18  # moved to Darcs as the primary repository, this is updated manually each 
 19  # time this file is changed. The last cvs_ver that was here was 1.51 . 
 20  command_version = "2.11" 
 21   
 22  # version history: 
 23  #  >=1.17: commands are interruptable 
 24  #  >=1.28: Arch understands 'revision', added Bazaar 
 25  #  >=1.33: Source classes understand 'retry' 
 26  #  >=1.39: Source classes correctly handle changes in branch (except Git) 
 27  #          Darcs accepts 'revision' (now all do but Git) (well, and P4Sync) 
 28  #          Arch/Baz should accept 'build-config' 
 29  #  >=1.51: (release 0.7.3) 
 30  #  >= 2.1: SlaveShellCommand now accepts 'initial_stdin', 'keep_stdin_open', 
 31  #          and 'logfiles'. It now sends 'log' messages in addition to 
 32  #          stdout/stdin/header/rc. It acquired writeStdin/closeStdin methods, 
 33  #          but these are not remotely callable yet. 
 34  #          (not externally visible: ShellCommandPP has writeStdin/closeStdin. 
 35  #          ShellCommand accepts new arguments (logfiles=, initialStdin=, 
 36  #          keepStdinOpen=) and no longer accepts stdin=) 
 37  #          (release 0.7.4) 
 38  #  >= 2.2: added monotone, uploadFile, and downloadFile (release 0.7.5) 
 39  #  >= 2.3: added bzr (release 0.7.6) 
 40  #  >= 2.4: Git understands 'revision' and branches 
 41  #  >= 2.5: workaround added for remote 'hg clone --rev REV' when hg<0.9.2 
 42  #  >= 2.6: added uploadDirectory 
 43  #  >= 2.7: added usePTY option to SlaveShellCommand 
 44  #  >= 2.8: added username and password args to SVN class 
 45  #  >= 2.9: add depth arg to SVN class 
 46  #  >= 2.10: CVS can handle 'extra_options' and 'export_options' 
 47  #  >= 2.11: Arch, Bazaar, and Monotone removed 
 48   
49 -class Command:
50 implements(ISlaveCommand) 51 52 """This class defines one command that can be invoked by the build master. 53 The command is executed on the slave side, and always sends back a 54 completion message when it finishes. It may also send intermediate status 55 as it runs (by calling builder.sendStatus). Some commands can be 56 interrupted (either by the build master or a local timeout), in which 57 case the step is expected to complete normally with a status message that 58 indicates an error occurred. 59 60 These commands are used by BuildSteps on the master side. Each kind of 61 BuildStep uses a single Command. The slave must implement all the 62 Commands required by the set of BuildSteps used for any given build: 63 this is checked at startup time. 64 65 All Commands are constructed with the same signature: 66 c = CommandClass(builder, stepid, args) 67 where 'builder' is the parent SlaveBuilder object, and 'args' is a 68 dict that is interpreted per-command. 69 70 The setup(args) method is available for setup, and is run from __init__. 71 72 The Command is started with start(). This method must be implemented in a 73 subclass, and it should return a Deferred. When your step is done, you 74 should fire the Deferred (the results are not used). If the command is 75 interrupted, it should fire the Deferred anyway. 76 77 While the command runs. it may send status messages back to the 78 buildmaster by calling self.sendStatus(statusdict). The statusdict is 79 interpreted by the master-side BuildStep however it likes. 80 81 A separate completion message is sent when the deferred fires, which 82 indicates that the Command has finished, but does not carry any status 83 data. If the Command needs to return an exit code of some sort, that 84 should be sent as a regular status message before the deferred is fired . 85 Once builder.commandComplete has been run, no more status messages may be 86 sent. 87 88 If interrupt() is called, the Command should attempt to shut down as 89 quickly as possible. Child processes should be killed, new ones should 90 not be started. The Command should send some kind of error status update, 91 then complete as usual by firing the Deferred. 92 93 .interrupted should be set by interrupt(), and can be tested to avoid 94 sending multiple error status messages. 95 96 If .running is False, the bot is shutting down (or has otherwise lost the 97 connection to the master), and should not send any status messages. This 98 is checked in Command.sendStatus . 99 100 """ 101 102 # builder methods: 103 # sendStatus(dict) (zero or more) 104 # commandComplete() or commandInterrupted() (one, at end) 105 106 debug = False 107 interrupted = False 108 running = False # set by Builder, cleared on shutdown or when the 109 # Deferred fires 110 111 _reactor = reactor 112
113 - def __init__(self, builder, stepId, args):
114 self.builder = builder 115 self.stepId = stepId # just for logging 116 self.args = args 117 self.setup(args)
118
119 - def setup(self, args):
120 """Override this in a subclass to extract items from the args dict.""" 121 pass
122
123 - def doStart(self):
124 self.running = True 125 d = defer.maybeDeferred(self.start) 126 def commandComplete(res): 127 self.running = False 128 return res
129 d.addBoth(commandComplete) 130 return d
131
132 - def start(self):
133 """Start the command. This method should return a Deferred that will 134 fire when the command has completed. The Deferred's argument will be 135 ignored. 136 137 This method should be overridden by subclasses.""" 138 raise NotImplementedError, "You must implement this in a subclass"
139
140 - def sendStatus(self, status):
141 """Send a status update to the master.""" 142 if self.debug: 143 log.msg("sendStatus", status) 144 if not self.running: 145 log.msg("would sendStatus but not .running") 146 return 147 self.builder.sendUpdate(status)
148
149 - def doInterrupt(self):
150 self.running = False 151 self.interrupt()
152
153 - def interrupt(self):
154 """Override this in a subclass to allow commands to be interrupted. 155 May be called multiple times, test and set self.interrupted=True if 156 this matters.""" 157 pass
158 159 # utility methods, mostly used by SlaveShellCommand and the like 160
161 - def _abandonOnFailure(self, rc):
162 if type(rc) is not int: 163 log.msg("weird, _abandonOnFailure was given rc=%s (%s)" % \ 164 (rc, type(rc))) 165 assert isinstance(rc, int) 166 if rc != 0: 167 raise AbandonChain(rc) 168 return rc
169
170 - def _sendRC(self, res):
171 self.sendStatus({'rc': 0})
172
173 - def _checkAbandoned(self, why):
174 log.msg("_checkAbandoned", why) 175 why.trap(AbandonChain) 176 log.msg(" abandoning chain", why.value) 177 self.sendStatus({'rc': why.value.args[0]}) 178 return None
179
180 -class SourceBaseCommand(Command):
181 """Abstract base class for Version Control System operations (checkout 182 and update). This class extracts the following arguments from the 183 dictionary received from the master: 184 185 - ['workdir']: (required) the subdirectory where the buildable sources 186 should be placed 187 188 - ['mode']: one of update/copy/clobber/export, defaults to 'update' 189 190 - ['revision']: (required) If not None, this is an int or string which indicates 191 which sources (along a time-like axis) should be used. 192 It is the thing you provide as the CVS -r or -D 193 argument. 194 195 - ['patch']: If not None, this is a tuple of (striplevel, patch) 196 which contains a patch that should be applied after the 197 checkout has occurred. Once applied, the tree is no 198 longer eligible for use with mode='update', and it only 199 makes sense to use this in conjunction with a 200 ['revision'] argument. striplevel is an int, and patch 201 is a string in standard unified diff format. The patch 202 will be applied with 'patch -p%d <PATCH', with 203 STRIPLEVEL substituted as %d. The command will fail if 204 the patch process fails (rejected hunks). 205 206 - ['timeout']: seconds of silence tolerated before we kill off the 207 command 208 209 - ['maxTime']: seconds before we kill off the command 210 211 - ['retry']: If not None, this is a tuple of (delay, repeats) 212 which means that any failed VC updates should be 213 reattempted, up to REPEATS times, after a delay of 214 DELAY seconds. This is intended to deal with slaves 215 that experience transient network failures. 216 """ 217 218 sourcedata = "" 219
220 - def setup(self, args):
221 # if we need to parse the output, use this environment. Otherwise 222 # command output will be in whatever the buildslave's native language 223 # has been set to. 224 self.env = os.environ.copy() 225 self.env['LC_MESSAGES'] = "C" 226 227 self.workdir = args['workdir'] 228 self.mode = args.get('mode', "update") 229 self.revision = args.get('revision') 230 self.patch = args.get('patch') 231 self.timeout = args.get('timeout', 120) 232 self.maxTime = args.get('maxTime', None) 233 self.retry = args.get('retry') 234 self._commandPaths = {}
235 # VC-specific subclasses should override this to extract more args. 236 # Make sure to upcall! 237
238 - def getCommand(self, name):
239 """Wrapper around utils.getCommand that will output a resonable 240 error message and raise AbandonChain if the command cannot be 241 found""" 242 if name not in self._commandPaths: 243 try: 244 self._commandPaths[name] = utils.getCommand(name) 245 except RuntimeError: 246 self.sendStatus({'stderr' : "could not find '%s'\n" % name}) 247 self.sendStatus({'stderr' : "PATH is '%s'\n" % os.environ.get('PATH', '')}) 248 raise AbandonChain(-1) 249 return self._commandPaths[name]
250
251 - def start(self):
252 self.sendStatus({'header': "starting " + self.header + "\n"}) 253 self.command = None 254 255 # self.srcdir is where the VC system should put the sources 256 if self.mode == "copy": 257 self.srcdir = "source" # hardwired directory name, sorry 258 else: 259 self.srcdir = self.workdir 260 261 self.sourcedatafile = os.path.join(self.builder.basedir, 262 ".buildbot-sourcedata") 263 # upgrade older versions to the new sourcedata location 264 old_sd_path = os.path.join(self.builder.basedir, self.srcdir, ".buildbot-sourcedata") 265 if os.path.exists(old_sd_path) and not os.path.exists(self.sourcedatafile): 266 os.rename(old_sd_path, self.sourcedatafile) 267 268 d = defer.succeed(None) 269 self.maybeClobber(d) 270 if not (self.sourcedirIsUpdateable() and self.sourcedataMatches()): 271 # the directory cannot be updated, so we have to clobber it. 272 # Perhaps the master just changed modes from 'export' to 273 # 'update'. 274 d.addCallback(self.doClobber, self.srcdir) 275 276 d.addCallback(self.doVC) 277 278 if self.mode == "copy": 279 d.addCallback(self.doCopy) 280 if self.patch: 281 d.addCallback(self.doPatch) 282 d.addCallbacks(self._sendRC, self._checkAbandoned) 283 return d
284
285 - def maybeClobber(self, d):
286 # do we need to clobber anything? 287 if self.mode in ("copy", "clobber", "export"): 288 d.addCallback(self.doClobber, self.workdir)
289
290 - def interrupt(self):
291 self.interrupted = True 292 if self.command: 293 self.command.kill("command interrupted")
294
295 - def doVC(self, res):
296 if self.interrupted: 297 raise AbandonChain(1) 298 if self.sourcedirIsUpdateable() and self.sourcedataMatches(): 299 d = self.doVCUpdate() 300 d.addBoth(self.maybeDoVCFallback) 301 else: 302 d = self.doVCFull() 303 d.addBoth(self.maybeDoVCRetry) 304 d.addCallback(self._abandonOnFailure) 305 d.addCallback(self._handleGotRevision) 306 d.addCallback(self.writeSourcedata) 307 return d
308
309 - def sourcedataMatches(self):
310 try: 311 olddata = self.readSourcedata() 312 if olddata != self.sourcedata: 313 return False 314 except IOError: 315 return False 316 return True
317
318 - def sourcedirIsPatched(self):
319 return os.path.exists(os.path.join(self.builder.basedir, 320 self.workdir, 321 ".buildbot-patched"))
322
323 - def _handleGotRevision(self, res):
324 d = defer.maybeDeferred(self.parseGotRevision) 325 d.addCallback(lambda got_revision: 326 self.sendStatus({'got_revision': got_revision})) 327 return d
328
329 - def parseGotRevision(self):
330 """Override this in a subclass. It should return a string that 331 represents which revision was actually checked out, or a Deferred 332 that will fire with such a string. If, in a future build, you were to 333 pass this 'got_revision' string in as the 'revision' component of a 334 SourceStamp, you should wind up with the same source code as this 335 checkout just obtained. 336 337 It is probably most useful to scan self.command.stdout for a string 338 of some sort. Be sure to set keepStdout=True on the VC command that 339 you run, so that you'll have something available to look at. 340 341 If this information is unavailable, just return None.""" 342 343 return None
344
345 - def readSourcedata(self):
346 return open(self.sourcedatafile, "r").read()
347
348 - def writeSourcedata(self, res):
349 open(self.sourcedatafile, "w").write(self.sourcedata) 350 return res
351
352 - def sourcedirIsUpdateable(self):
353 """Returns True if the tree can be updated.""" 354 raise NotImplementedError("this must be implemented in a subclass")
355
356 - def doVCUpdate(self):
357 """Returns a deferred with the steps to update a checkout.""" 358 raise NotImplementedError("this must be implemented in a subclass")
359
360 - def doVCFull(self):
361 """Returns a deferred with the steps to do a fresh checkout.""" 362 raise NotImplementedError("this must be implemented in a subclass")
363
364 - def maybeDoVCFallback(self, rc):
365 if type(rc) is int and rc == 0: 366 return rc 367 if self.interrupted: 368 raise AbandonChain(1) 369 # Let VCS subclasses have an opportunity to handle 370 # unrecoverable errors without having to clobber the repo 371 self.maybeNotDoVCFallback(rc) 372 msg = "update failed, clobbering and trying again" 373 self.sendStatus({'header': msg + "\n"}) 374 log.msg(msg) 375 d = self.doClobber(None, self.srcdir) 376 d.addCallback(self.doVCFallback2) 377 return d
378
379 - def doVCFallback2(self, res):
380 msg = "now retrying VC operation" 381 self.sendStatus({'header': msg + "\n"}) 382 log.msg(msg) 383 d = self.doVCFull() 384 d.addBoth(self.maybeDoVCRetry) 385 d.addCallback(self._abandonOnFailure) 386 return d
387
388 - def maybeNotDoVCFallback(self, rc):
389 """Override this in a subclass if you want to detect unrecoverable 390 checkout errors where clobbering the repo wouldn't help, and stop 391 the current VC chain before it clobbers the repo for future builds. 392 393 Use 'raise AbandonChain' to pass up a halt if you do detect such.""" 394 pass
395
396 - def maybeDoVCRetry(self, res):
397 """We get here somewhere after a VC chain has finished. res could 398 be:: 399 400 - 0: the operation was successful 401 - nonzero: the operation failed. retry if possible 402 - AbandonChain: the operation failed, someone else noticed. retry. 403 - Failure: some other exception, re-raise 404 """ 405 406 if isinstance(res, failure.Failure): 407 if self.interrupted: 408 return res # don't re-try interrupted builds 409 res.trap(AbandonChain) 410 else: 411 if type(res) is int and res == 0: 412 return res 413 if self.interrupted: 414 raise AbandonChain(1) 415 # if we get here, we should retry, if possible 416 if self.retry: 417 delay, repeats = self.retry 418 if repeats >= 0: 419 self.retry = (delay, repeats-1) 420 msg = ("update failed, trying %d more times after %d seconds" 421 % (repeats, delay)) 422 self.sendStatus({'header': msg + "\n"}) 423 log.msg(msg) 424 d = defer.Deferred() 425 # we are going to do a full checkout, so a clobber is 426 # required first 427 self.doClobber(d, self.workdir) 428 if self.srcdir: 429 self.doClobber(d, self.srcdir) 430 d.addCallback(lambda res: self.doVCFull()) 431 d.addBoth(self.maybeDoVCRetry) 432 self._reactor.callLater(delay, d.callback, None) 433 return d 434 return res
435
436 - def doClobber(self, dummy, dirname, chmodDone=False):
437 # TODO: remove the old tree in the background 438 ## workdir = os.path.join(self.builder.basedir, self.workdir) 439 ## deaddir = self.workdir + ".deleting" 440 ## if os.path.isdir(workdir): 441 ## try: 442 ## os.rename(workdir, deaddir) 443 ## # might fail if deaddir already exists: previous deletion 444 ## # hasn't finished yet 445 ## # start the deletion in the background 446 ## # TODO: there was a solaris/NetApp/NFS problem where a 447 ## # process that was still running out of the directory we're 448 ## # trying to delete could prevent the rm-rf from working. I 449 ## # think it stalled the rm, but maybe it just died with 450 ## # permission issues. Try to detect this. 451 ## os.commands("rm -rf %s &" % deaddir) 452 ## except: 453 ## # fall back to sequential delete-then-checkout 454 ## pass 455 d = os.path.join(self.builder.basedir, dirname) 456 if runtime.platformType != "posix": 457 # if we're running on w32, use rmtree instead. It will block, 458 # but hopefully it won't take too long. 459 utils.rmdirRecursive(d) 460 return defer.succeed(0) 461 command = ["rm", "-rf", d] 462 c = runprocess.RunProcess(self.builder, command, self.builder.basedir, 463 sendRC=0, timeout=self.timeout, maxTime=self.maxTime, 464 usePTY=False) 465 466 self.command = c 467 # sendRC=0 means the rm command will send stdout/stderr to the 468 # master, but not the rc=0 when it finishes. That job is left to 469 # _sendRC 470 d = c.start() 471 # The rm -rf may fail if there is a left-over subdir with chmod 000 472 # permissions. So if we get a failure, we attempt to chmod suitable 473 # permissions and re-try the rm -rf. 474 if chmodDone: 475 d.addCallback(self._abandonOnFailure) 476 else: 477 d.addCallback(lambda rc: self.doClobberTryChmodIfFail(rc, dirname)) 478 return d
479
480 - def doClobberTryChmodIfFail(self, rc, dirname):
481 assert isinstance(rc, int) 482 if rc == 0: 483 return defer.succeed(0) 484 # Attempt a recursive chmod and re-try the rm -rf after. 485 486 command = ["chmod", "-Rf", "u+rwx", os.path.join(self.builder.basedir, dirname)] 487 if sys.platform.startswith('freebsd'): 488 # Work around a broken 'chmod -R' on FreeBSD (it tries to recurse into a 489 # directory for which it doesn't have permission, before changing that 490 # permission) by running 'find' instead 491 command = ["find", os.path.join(self.builder.basedir, dirname), 492 '-exec', 'chmod', 'u+rwx', '{}', ';' ] 493 c = runprocess.RunProcess(self.builder, command, self.builder.basedir, 494 sendRC=0, timeout=self.timeout, maxTime=self.maxTime, 495 usePTY=False) 496 497 self.command = c 498 d = c.start() 499 d.addCallback(self._abandonOnFailure) 500 d.addCallback(lambda dummy: self.doClobber(dummy, dirname, True)) 501 return d
502
503 - def doCopy(self, res):
504 # now copy tree to workdir 505 fromdir = os.path.join(self.builder.basedir, self.srcdir) 506 todir = os.path.join(self.builder.basedir, self.workdir) 507 if runtime.platformType != "posix": 508 self.sendStatus({'header': "Since we're on a non-POSIX platform, " 509 "we're not going to try to execute cp in a subprocess, but instead " 510 "use shutil.copytree(), which will block until it is complete. " 511 "fromdir: %s, todir: %s\n" % (fromdir, todir)}) 512 shutil.copytree(fromdir, todir) 513 return defer.succeed(0) 514 515 if not os.path.exists(os.path.dirname(todir)): 516 os.makedirs(os.path.dirname(todir)) 517 if os.path.exists(todir): 518 # I don't think this happens, but just in case.. 519 log.msg("cp target '%s' already exists -- cp will not do what you think!" % todir) 520 521 command = ['cp', '-R', '-P', '-p', fromdir, todir] 522 c = runprocess.RunProcess(self.builder, command, self.builder.basedir, 523 sendRC=False, timeout=self.timeout, maxTime=self.maxTime, 524 usePTY=False) 525 self.command = c 526 d = c.start() 527 d.addCallback(self._abandonOnFailure) 528 return d
529
530 - def doPatch(self, res):
531 patchlevel = self.patch[0] 532 diff = self.patch[1] 533 root = None 534 if len(self.patch) >= 3: 535 root = self.patch[2] 536 command = [ 537 utils.getCommand("patch"), 538 '-p%d' % patchlevel, 539 '--remove-empty-files', 540 '--force', 541 '--forward', 542 '-i', '.buildbot-diff', 543 ] 544 dir = os.path.join(self.builder.basedir, self.workdir) 545 # Mark the directory so we don't try to update it later, or at least try 546 # to revert first. 547 open(os.path.join(dir, ".buildbot-patched"), "w").write("patched\n") 548 549 # write the diff to a file, for reading later 550 open(os.path.join(dir, ".buildbot-diff"), "w").write(diff) 551 552 # Update 'dir' with the 'root' option. Make sure it is a subdirectory 553 # of dir. 554 if (root and 555 os.path.abspath(os.path.join(dir, root) 556 ).startswith(os.path.abspath(dir))): 557 dir = os.path.join(dir, root) 558 559 # now apply the patch 560 c = runprocess.RunProcess(self.builder, command, dir, 561 sendRC=False, timeout=self.timeout, 562 maxTime=self.maxTime, usePTY=False) 563 self.command = c 564 d = c.start() 565 566 # clean up the temp file 567 def cleanup(x): 568 try: 569 os.unlink(os.path.join(dir, ".buildbot-diff")) 570 except: 571 pass 572 return x
573 d.addBoth(cleanup) 574 575 d.addCallback(self._abandonOnFailure) 576 return d
577