1
2
3
4
5
6
7
8
9
10
11
12
13
14
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, threads, 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 from buildslave import util
31
32
33
34
35 command_version = "2.16"
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
70 implements(ISlaveCommand)
71
72 """This class defines one command that can be invoked by the build master.
73 The command is executed on the slave side, and always sends back a
74 completion message when it finishes. It may also send intermediate status
75 as it runs (by calling builder.sendStatus). Some commands can be
76 interrupted (either by the build master or a local timeout), in which
77 case the step is expected to complete normally with a status message that
78 indicates an error occurred.
79
80 These commands are used by BuildSteps on the master side. Each kind of
81 BuildStep uses a single Command. The slave must implement all the
82 Commands required by the set of BuildSteps used for any given build:
83 this is checked at startup time.
84
85 All Commands are constructed with the same signature:
86 c = CommandClass(builder, stepid, args)
87 where 'builder' is the parent SlaveBuilder object, and 'args' is a
88 dict that is interpreted per-command.
89
90 The setup(args) method is available for setup, and is run from __init__.
91
92 The Command is started with start(). This method must be implemented in a
93 subclass, and it should return a Deferred. When your step is done, you
94 should fire the Deferred (the results are not used). If the command is
95 interrupted, it should fire the Deferred anyway.
96
97 While the command runs. it may send status messages back to the
98 buildmaster by calling self.sendStatus(statusdict). The statusdict is
99 interpreted by the master-side BuildStep however it likes.
100
101 A separate completion message is sent when the deferred fires, which
102 indicates that the Command has finished, but does not carry any status
103 data. If the Command needs to return an exit code of some sort, that
104 should be sent as a regular status message before the deferred is fired .
105 Once builder.commandComplete has been run, no more status messages may be
106 sent.
107
108 If interrupt() is called, the Command should attempt to shut down as
109 quickly as possible. Child processes should be killed, new ones should
110 not be started. The Command should send some kind of error status update,
111 then complete as usual by firing the Deferred.
112
113 .interrupted should be set by interrupt(), and can be tested to avoid
114 sending multiple error status messages.
115
116 If .running is False, the bot is shutting down (or has otherwise lost the
117 connection to the master), and should not send any status messages. This
118 is checked in Command.sendStatus .
119
120 """
121
122
123
124
125
126 debug = False
127 interrupted = False
128 running = False
129
130
131 _reactor = reactor
132
133 - def __init__(self, builder, stepId, args):
139
141 """Override this in a subclass to extract items from the args dict."""
142 pass
143
152 d.addBoth(commandComplete)
153 return d
154
156 """Start the command. This method should return a Deferred that will
157 fire when the command has completed. The Deferred's argument will be
158 ignored.
159
160 This method should be overridden by subclasses."""
161 raise NotImplementedError, "You must implement this in a subclass"
162
171
175
177 """Override this in a subclass to allow commands to be interrupted.
178 May be called multiple times, test and set self.interrupted=True if
179 this matters."""
180 pass
181
182
183
192
195
197 log.msg("_checkAbandoned", why)
198 why.trap(AbandonChain)
199 log.msg(" abandoning chain", why.value)
200 self.sendStatus({'rc': why.value.args[0]})
201 return None
202
204 """Abstract base class for Version Control System operations (checkout
205 and update). This class extracts the following arguments from the
206 dictionary received from the master:
207
208 - ['workdir']: (required) the subdirectory where the buildable sources
209 should be placed
210
211 - ['mode']: one of update/copy/clobber/export, defaults to 'update'
212
213 - ['revision']: (required) If not None, this is an int or string which indicates
214 which sources (along a time-like axis) should be used.
215 It is the thing you provide as the CVS -r or -D
216 argument.
217
218 - ['patch']: If not None, this is a tuple of (striplevel, patch)
219 which contains a patch that should be applied after the
220 checkout has occurred. Once applied, the tree is no
221 longer eligible for use with mode='update', and it only
222 makes sense to use this in conjunction with a
223 ['revision'] argument. striplevel is an int, and patch
224 is a string in standard unified diff format. The patch
225 will be applied with 'patch -p%d <PATCH', with
226 STRIPLEVEL substituted as %d. The command will fail if
227 the patch process fails (rejected hunks).
228
229 - ['timeout']: seconds of silence tolerated before we kill off the
230 command
231
232 - ['maxTime']: seconds before we kill off the command
233
234 - ['retry']: If not None, this is a tuple of (delay, repeats)
235 which means that any failed VC updates should be
236 reattempted, up to REPEATS times, after a delay of
237 DELAY seconds. This is intended to deal with slaves
238 that experience transient network failures.
239 """
240
241 sourcedata = ""
242
244
245
246
247 self.env = os.environ.copy()
248 self.env['LC_MESSAGES'] = "C"
249
250 self.workdir = args['workdir']
251 self.mode = args.get('mode', "update")
252 self.revision = args.get('revision')
253 self.patch = args.get('patch')
254 self.timeout = args.get('timeout', 120)
255 self.maxTime = args.get('maxTime', None)
256 self.retry = args.get('retry')
257 self.logEnviron = args.get('logEnviron',True)
258 self._commandPaths = {}
259
260
261
263 """Wrapper around utils.getCommand that will output a resonable
264 error message and raise AbandonChain if the command cannot be
265 found"""
266 if name not in self._commandPaths:
267 try:
268 self._commandPaths[name] = utils.getCommand(name)
269 except RuntimeError:
270 self.sendStatus({'stderr' : "could not find '%s'\n" % name})
271 self.sendStatus({'stderr' : "PATH is '%s'\n" % os.environ.get('PATH', '')})
272 raise AbandonChain(-1)
273 return self._commandPaths[name]
274
276 self.sendStatus({'header': "starting " + self.header + "\n"})
277 self.command = None
278
279
280 if self.mode == "copy":
281 self.srcdir = "source"
282 else:
283 self.srcdir = self.workdir
284
285 self.sourcedatafile = os.path.join(self.builder.basedir,
286 ".buildbot-sourcedata-" + b64encode(self.srcdir))
287
288
289 old_sd_path = os.path.join(self.builder.basedir, self.srcdir, ".buildbot-sourcedata")
290 if os.path.exists(old_sd_path) and not os.path.exists(self.sourcedatafile):
291 os.rename(old_sd_path, self.sourcedatafile)
292
293
294
295 old_sd_path = os.path.join(self.builder.basedir, ".buildbot-sourcedata")
296 if os.path.exists(old_sd_path) and not os.path.exists(self.sourcedatafile):
297 os.rename(old_sd_path, self.sourcedatafile)
298
299 d = defer.succeed(None)
300 self.maybeClobber(d)
301 if not (self.sourcedirIsUpdateable() and self.sourcedataMatches()):
302
303
304
305 d.addCallback(self.doClobber, self.srcdir)
306
307 d.addCallback(self.doVC)
308
309 if self.mode == "copy":
310 d.addCallback(self.doCopy)
311 if self.patch:
312 d.addCallback(self.doPatch)
313 d.addCallbacks(self._sendRC, self._checkAbandoned)
314 return d
315
317
318 if self.mode in ("copy", "clobber", "export"):
319 d.addCallback(self.doClobber, self.workdir)
320
325
326 - def doVC(self, res):
339
341 try:
342 olddata = self.readSourcedata()
343 if olddata != self.sourcedata:
344 return False
345 except IOError:
346 return False
347 return True
348
353
355 d = defer.maybeDeferred(self.parseGotRevision)
356 d.addCallback(lambda got_revision:
357 self.sendStatus({'got_revision': got_revision}))
358 return d
359
361 """Override this in a subclass. It should return a string that
362 represents which revision was actually checked out, or a Deferred
363 that will fire with such a string. If, in a future build, you were to
364 pass this 'got_revision' string in as the 'revision' component of a
365 SourceStamp, you should wind up with the same source code as this
366 checkout just obtained.
367
368 It is probably most useful to scan self.command.stdout for a string
369 of some sort. Be sure to set keepStdout=True on the VC command that
370 you run, so that you'll have something available to look at.
371
372 If this information is unavailable, just return None."""
373
374 return None
375
377 """
378 Read the sourcedata file and return its contents
379
380 @returns: source data
381 @raises: IOError if the file does not exist
382 """
383 return open(self.sourcedatafile, "r").read()
384
388
390 """Returns True if the tree can be updated."""
391 raise NotImplementedError("this must be implemented in a subclass")
392
394 """Returns a deferred with the steps to update a checkout."""
395 raise NotImplementedError("this must be implemented in a subclass")
396
398 """Returns a deferred with the steps to do a fresh checkout."""
399 raise NotImplementedError("this must be implemented in a subclass")
400
421
423 msg = "now retrying VC operation"
424 self.sendStatus({'header': msg + "\n"})
425 log.msg(msg)
426 d = self.doVCFull()
427 d.addBoth(self.maybeDoVCRetry)
428 d.addCallback(self._abandonOnFailure)
429 return d
430
432 """Override this in a subclass if you want to detect unrecoverable
433 checkout errors where clobbering the repo wouldn't help, and stop
434 the current VC chain before it clobbers the repo for future builds.
435
436 Use 'raise AbandonChain' to pass up a halt if you do detect such."""
437 pass
438
440 """We get here somewhere after a VC chain has finished. res could
441 be::
442
443 - 0: the operation was successful
444 - nonzero: the operation failed. retry if possible
445 - AbandonChain: the operation failed, someone else noticed. retry.
446 - Failure: some other exception, re-raise
447 """
448
449 if isinstance(res, failure.Failure):
450 if self.interrupted:
451 return res
452 res.trap(AbandonChain)
453 else:
454 if type(res) is int and res == 0:
455 return res
456 if self.interrupted:
457 raise AbandonChain(1)
458
459 if self.retry:
460 delay, repeats = self.retry
461 if repeats >= 0:
462 self.retry = (delay, repeats-1)
463 msg = ("update failed, trying %d more times after %d seconds"
464 % (repeats, delay))
465 self.sendStatus({'header': msg + "\n"})
466 log.msg(msg)
467 d = defer.Deferred()
468
469
470 self.doClobber(d, self.workdir)
471 if self.srcdir:
472 self.doClobber(d, self.srcdir)
473 d.addCallback(lambda res: self.doVCFull())
474 d.addBoth(self.maybeDoVCRetry)
475 self._reactor.callLater(delay, d.callback, None)
476 return d
477 return res
478
479 - def doClobber(self, dummy, dirname, chmodDone=False):
480 d = os.path.join(self.builder.basedir, dirname)
481 if runtime.platformType != "posix":
482 d = threads.deferToThread(utils.rmdirRecursive, d)
483 def cb(_):
484 return 0
485 def eb(f):
486 self.sendStatus({'header' : 'exception from rmdirRecursive\n' + f.getTraceback()})
487 return -1
488 d.addCallbacks(cb, eb)
489 return d
490 command = ["rm", "-rf", d]
491 c = runprocess.RunProcess(self.builder, command, self.builder.basedir,
492 sendRC=0, timeout=self.timeout, maxTime=self.maxTime,
493 logEnviron=self.logEnviron, usePTY=False)
494
495 self.command = c
496
497
498
499 d = c.start()
500
501
502
503 if chmodDone:
504 d.addCallback(self._abandonOnFailure)
505 else:
506 d.addCallback(lambda rc: self.doClobberTryChmodIfFail(rc, dirname))
507 return d
508
510 assert isinstance(rc, int)
511 if rc == 0:
512 return defer.succeed(0)
513
514
515 command = ["chmod", "-Rf", "u+rwx", os.path.join(self.builder.basedir, dirname)]
516 if sys.platform.startswith('freebsd'):
517
518
519
520 command = ["find", os.path.join(self.builder.basedir, dirname),
521 '-exec', 'chmod', 'u+rwx', '{}', ';' ]
522 c = runprocess.RunProcess(self.builder, command, self.builder.basedir,
523 sendRC=0, timeout=self.timeout, maxTime=self.maxTime,
524 logEnviron=self.logEnviron, usePTY=False)
525
526 self.command = c
527 d = c.start()
528 d.addCallback(self._abandonOnFailure)
529 d.addCallback(lambda dummy: self.doClobber(dummy, dirname, True))
530 return d
531
533
534 fromdir = os.path.join(self.builder.basedir, self.srcdir)
535 todir = os.path.join(self.builder.basedir, self.workdir)
536 if runtime.platformType != "posix":
537 d = threads.deferToThread(shutil.copytree, fromdir, todir)
538 def cb(_):
539 return 0
540 def eb(f):
541 self.sendStatus({'header' : 'exception from copytree\n' + f.getTraceback()})
542 return -1
543 d.addCallbacks(cb, eb)
544 return d
545
546 if not os.path.exists(os.path.dirname(todir)):
547 os.makedirs(os.path.dirname(todir))
548 if os.path.exists(todir):
549
550 log.msg("cp target '%s' already exists -- cp will not do what you think!" % todir)
551
552 command = ['cp', '-R', '-P', '-p', fromdir, todir]
553 c = runprocess.RunProcess(self.builder, command, self.builder.basedir,
554 sendRC=False, timeout=self.timeout, maxTime=self.maxTime,
555 logEnviron=self.logEnviron, usePTY=False)
556 self.command = c
557 d = c.start()
558 d.addCallback(self._abandonOnFailure)
559 return d
560
562 patchlevel = self.patch[0]
563 diff = self.patch[1]
564 root = None
565 if len(self.patch) >= 3:
566 root = self.patch[2]
567 command = [
568 utils.getCommand("patch"),
569 '-p%d' % patchlevel,
570 '--remove-empty-files',
571 '--force',
572 '--forward',
573 '-i', '.buildbot-diff',
574 ]
575 dir = os.path.join(self.builder.basedir, self.workdir)
576
577
578 open(os.path.join(dir, ".buildbot-patched"), "w").write("patched\n")
579
580
581 open(os.path.join(dir, ".buildbot-diff"), "w").write(diff)
582
583
584
585 if (root and
586 os.path.abspath(os.path.join(dir, root)
587 ).startswith(os.path.abspath(dir))):
588 dir = os.path.join(dir, root)
589
590
591 c = runprocess.RunProcess(self.builder, command, dir,
592 sendRC=False, timeout=self.timeout,
593 maxTime=self.maxTime, logEnviron=self.logEnviron,
594 usePTY=False)
595 self.command = c
596 d = c.start()
597
598
599 def cleanup(x):
600 try:
601 os.unlink(os.path.join(dir, ".buildbot-diff"))
602 except:
603 pass
604 return x
605 d.addBoth(cleanup)
606
607 d.addCallback(self._abandonOnFailure)
608 return d
609
610 - def setFileContents(self, filename, contents):
611 """Put the given C{contents} in C{filename}; this is a bit more
612 succinct than opening, writing, and closing, and has the advantage of
613 being patchable in tests. Note that the enclosing directory is
614 not automatically created, nor is this an "atomic" overwrite."""
615 f = open(filename, 'w')
616 f.write(contents)
617 f.close()
618