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
18
19
20 command_version = "2.11"
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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
103
104
105
106 debug = False
107 interrupted = False
108 running = False
109
110
111 _reactor = reactor
112
113 - def __init__(self, builder, stepId, args):
118
120 """Override this in a subclass to extract items from the args dict."""
121 pass
122
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
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
148
152
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
160
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
172
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
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
221
222
223
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
236
237
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
252 self.sendStatus({'header': "starting " + self.header + "\n"})
253 self.command = None
254
255
256 if self.mode == "copy":
257 self.srcdir = "source"
258 else:
259 self.srcdir = self.workdir
260
261 self.sourcedatafile = os.path.join(self.builder.basedir,
262 ".buildbot-sourcedata")
263
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
272
273
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
286
287 if self.mode in ("copy", "clobber", "export"):
288 d.addCallback(self.doClobber, self.workdir)
289
294
295 - def doVC(self, res):
308
310 try:
311 olddata = self.readSourcedata()
312 if olddata != self.sourcedata:
313 return False
314 except IOError:
315 return False
316 return True
317
322
324 d = defer.maybeDeferred(self.parseGotRevision)
325 d.addCallback(lambda got_revision:
326 self.sendStatus({'got_revision': got_revision}))
327 return d
328
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
346 return open(self.sourcedatafile, "r").read()
347
349 open(self.sourcedatafile, "w").write(self.sourcedata)
350 return res
351
353 """Returns True if the tree can be updated."""
354 raise NotImplementedError("this must be implemented in a subclass")
355
357 """Returns a deferred with the steps to update a checkout."""
358 raise NotImplementedError("this must be implemented in a subclass")
359
361 """Returns a deferred with the steps to do a fresh checkout."""
362 raise NotImplementedError("this must be implemented in a subclass")
363
378
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
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
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
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
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
426
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
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455 d = os.path.join(self.builder.basedir, dirname)
456 if runtime.platformType != "posix":
457
458
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
468
469
470 d = c.start()
471
472
473
474 if chmodDone:
475 d.addCallback(self._abandonOnFailure)
476 else:
477 d.addCallback(lambda rc: self.doClobberTryChmodIfFail(rc, dirname))
478 return d
479
481 assert isinstance(rc, int)
482 if rc == 0:
483 return defer.succeed(0)
484
485
486 command = ["chmod", "-Rf", "u+rwx", os.path.join(self.builder.basedir, dirname)]
487 if sys.platform.startswith('freebsd'):
488
489
490
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
504
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
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
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
546
547 open(os.path.join(dir, ".buildbot-patched"), "w").write("patched\n")
548
549
550 open(os.path.join(dir, ".buildbot-diff"), "w").write(diff)
551
552
553
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
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
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