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