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

Source Code for Module buildslave.commands.base

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