1 # -*- test-case-name: buildbot.test.test_scheduler,buildbot.test.test_vc -*-
3 import sys
, os
, re
, time
, random
4 from twisted
.internet
import utils
, protocol
, defer
, reactor
, task
5 from twisted
.spread
import pb
6 from twisted
.cred
import credentials
7 from twisted
.python
import log
8 from twisted
.python
.procutils
import which
10 from buildbot
.sourcestamp
import SourceStamp
11 from buildbot
.scripts
import runner
12 from buildbot
.util
import now
13 from buildbot
.status
import builder
15 class SourceStampExtractor
:
17 def __init__(self
, treetop
, branch
):
18 self
.treetop
= treetop
20 self
.exe
= which(self
.vcexe
)[0]
23 """This accepts the arguments of a command, without the actual
25 env
= os
.environ
.copy()
27 d
= utils
.getProcessOutputAndValue(self
.exe
, cmd
, env
=env
,
29 d
.addCallback(self
._didvc
, cmd
)
31 def _didvc(self
, res
, cmd
):
32 (stdout
, stderr
, code
) = res
33 # 'bzr diff' sets rc=1 if there were any differences. tla, baz, and
34 # cvs do something similar, so don't bother requring rc=0.
38 """Return a Deferred that fires with a SourceStamp instance."""
39 d
= self
.getBaseRevision()
40 d
.addCallback(self
.getPatch
)
41 d
.addCallback(self
.done
)
43 def readPatch(self
, res
, patchlevel
):
44 self
.patch
= (patchlevel
, res
)
46 # TODO: figure out the branch too
47 ss
= SourceStamp(self
.branch
, self
.baserev
, self
.patch
)
50 class CVSExtractor(SourceStampExtractor
):
53 def getBaseRevision(self
):
54 # this depends upon our local clock and the repository's clock being
55 # reasonably synchronized with each other. We express everything in
56 # UTC because the '%z' format specifier for strftime doesn't always
58 self
.baserev
= time
.strftime("%Y-%m-%d %H:%M:%S +0000",
60 return defer
.succeed(None)
62 def getPatch(self
, res
):
63 # the -q tells CVS to not announce each directory as it works
64 if self
.branch
is not None:
65 # 'cvs diff' won't take both -r and -D at the same time (it
66 # ignores the -r). As best I can tell, there is no way to make
67 # cvs give you a diff relative to a timestamp on the non-trunk
68 # branch. A bare 'cvs diff' will tell you about the changes
69 # relative to your checked-out versions, but I know of no way to
70 # find out what those checked-out versions are.
71 raise RuntimeError("Sorry, CVS 'try' builds don't work with "
73 args
= ['-q', 'diff', '-u', '-D', self
.baserev
]
75 d
.addCallback(self
.readPatch
, self
.patchlevel
)
78 class SVNExtractor(SourceStampExtractor
):
82 def getBaseRevision(self
):
83 d
= self
.dovc(["status", "-u"])
84 d
.addCallback(self
.parseStatus
)
86 def parseStatus(self
, res
):
87 # svn shows the base revision for each file that has been modified or
88 # which needs an update. You can update each file to a different
89 # version, so each file is displayed with its individual base
90 # revision. It also shows the repository-wide latest revision number
91 # on the last line ("Status against revision: \d+").
93 # for our purposes, we use the latest revision number as the "base"
94 # revision, and get a diff against that. This means we will get
95 # reverse-diffs for local files that need updating, but the resulting
96 # tree will still be correct. The only weirdness is that the baserev
97 # that we emit may be different than the version of the tree that we
100 # to do this differently would probably involve scanning the revision
101 # numbers to find the max (or perhaps the min) revision, and then
102 # using that as a base.
104 for line
in res
.split("\n"):
105 m
= re
.search(r
'^Status against revision:\s+(\d+)', line
)
107 self
.baserev
= int(m
.group(1))
109 raise IndexError("Could not find 'Status against revision' in "
110 "SVN output: %s" % res
)
111 def getPatch(self
, res
):
112 d
= self
.dovc(["diff", "-r%d" % self
.baserev
])
113 d
.addCallback(self
.readPatch
, self
.patchlevel
)
116 class BazExtractor(SourceStampExtractor
):
119 def getBaseRevision(self
):
120 d
= self
.dovc(["tree-id"])
121 d
.addCallback(self
.parseStatus
)
123 def parseStatus(self
, res
):
125 slash
= tid
.index("/")
126 dd
= tid
.rindex("--")
127 self
.branch
= tid
[slash
+1:dd
]
128 self
.baserev
= tid
[dd
+2:]
129 def getPatch(self
, res
):
130 d
= self
.dovc(["diff"])
131 d
.addCallback(self
.readPatch
, self
.patchlevel
)
134 class TlaExtractor(SourceStampExtractor
):
137 def getBaseRevision(self
):
138 # 'tla logs --full' gives us ARCHIVE/BRANCH--REVISION
139 # 'tla logs' gives us REVISION
140 d
= self
.dovc(["logs", "--full", "--reverse"])
141 d
.addCallback(self
.parseStatus
)
143 def parseStatus(self
, res
):
144 tid
= res
.split("\n")[0].strip()
145 slash
= tid
.index("/")
146 dd
= tid
.rindex("--")
147 self
.branch
= tid
[slash
+1:dd
]
148 self
.baserev
= tid
[dd
+2:]
150 def getPatch(self
, res
):
151 d
= self
.dovc(["changes", "--diffs"])
152 d
.addCallback(self
.readPatch
, self
.patchlevel
)
155 class BzrExtractor(SourceStampExtractor
):
158 def getBaseRevision(self
):
159 d
= self
.dovc(["version-info"])
160 d
.addCallback(self
.get_revision_number
)
162 def get_revision_number(self
, out
):
163 for line
in out
.split("\n"):
164 colon
= line
.find(":")
166 key
, value
= line
[:colon
], line
[colon
+2:]
168 self
.baserev
= int(value
)
170 raise ValueError("unable to find revno: in bzr output: '%s'" % out
)
172 def getPatch(self
, res
):
173 d
= self
.dovc(["diff"])
174 d
.addCallback(self
.readPatch
, self
.patchlevel
)
177 class MercurialExtractor(SourceStampExtractor
):
180 def getBaseRevision(self
):
181 d
= self
.dovc(["identify"])
182 d
.addCallback(self
.parseStatus
)
184 def parseStatus(self
, output
):
185 m
= re
.search(r
'^(\w+)', output
)
186 self
.baserev
= m
.group(0)
187 def getPatch(self
, res
):
188 d
= self
.dovc(["diff"])
189 d
.addCallback(self
.readPatch
, self
.patchlevel
)
192 class DarcsExtractor(SourceStampExtractor
):
195 def getBaseRevision(self
):
196 d
= self
.dovc(["changes", "--context"])
197 d
.addCallback(self
.parseStatus
)
199 def parseStatus(self
, res
):
200 self
.baserev
= res
# the whole context file
201 def getPatch(self
, res
):
202 d
= self
.dovc(["diff", "-u"])
203 d
.addCallback(self
.readPatch
, self
.patchlevel
)
206 def getSourceStamp(vctype
, treetop
, branch
=None):
208 e
= CVSExtractor(treetop
, branch
)
209 elif vctype
== "svn":
210 e
= SVNExtractor(treetop
, branch
)
211 elif vctype
== "baz":
212 e
= BazExtractor(treetop
, branch
)
213 elif vctype
== "bzr":
214 e
= BzrExtractor(treetop
, branch
)
215 elif vctype
== "tla":
216 e
= TlaExtractor(treetop
, branch
)
218 e
= MercurialExtractor(treetop
, branch
)
219 elif vctype
== "darcs":
220 e
= DarcsExtractor(treetop
, branch
)
222 raise KeyError("unknown vctype '%s'" % vctype
)
227 return "%d:%s," % (len(s
), s
)
229 def createJobfile(bsid
, branch
, baserev
, patchlevel
, diff
, builderNames
):
234 job
+= ns(str(baserev
))
235 job
+= ns("%d" % patchlevel
)
237 for bn
in builderNames
:
241 def getTopdir(topfile
, start
=None):
242 """walk upwards from the current directory until we find this topfile"""
248 if os
.path
.exists(os
.path
.join(here
, topfile
)):
250 next
= os
.path
.dirname(here
)
252 break # we've hit the root
255 raise ValueError("Unable to find topfile '%s' anywhere from %s upwards"
258 class RemoteTryPP(protocol
.ProcessProtocol
):
259 def __init__(self
, job
):
261 self
.d
= defer
.Deferred()
262 def connectionMade(self
):
263 self
.transport
.write(self
.job
)
264 self
.transport
.closeStdin()
265 def outReceived(self
, data
):
266 sys
.stdout
.write(data
)
267 def errReceived(self
, data
):
268 sys
.stderr
.write(data
)
269 def processEnded(self
, status_object
):
270 sig
= status_object
.value
.signal
271 rc
= status_object
.value
.exitCode
272 if sig
!= None or rc
!= 0:
273 self
.d
.errback(RuntimeError("remote 'buildbot tryserver' failed"
274 ": sig=%s, rc=%s" % (sig
, rc
)))
276 self
.d
.callback((sig
, rc
))
278 class BuildSetStatusGrabber
:
279 retryCount
= 5 # how many times to we try to grab the BuildSetStatus?
280 retryDelay
= 3 # seconds to wait between attempts
282 def __init__(self
, status
, bsid
):
287 # return a Deferred that either fires with the BuildSetStatus
288 # reference or errbacks because we were unable to grab it
289 self
.d
= defer
.Deferred()
290 # wait a second before querying to give the master's maildir watcher
291 # a chance to see the job
292 reactor
.callLater(1, self
.go
)
295 def go(self
, dummy
=None):
296 if self
.retryCount
== 0:
297 raise RuntimeError("couldn't find matching buildset")
299 d
= self
.status
.callRemote("getBuildSets")
300 d
.addCallback(self
._gotSets
)
302 def _gotSets(self
, buildsets
):
303 for bs
,bsid
in buildsets
:
304 if bsid
== self
.bsid
:
309 d
.addCallback(self
.go
)
310 reactor
.callLater(self
.retryDelay
, d
.callback
, None)
313 class Try(pb
.Referenceable
):
314 buildsetStatus
= None
317 def __init__(self
, config
):
319 self
.opts
= runner
.loadOptions()
320 self
.connect
= self
.getopt('connect', 'try_connect')
321 assert self
.connect
, "you must specify a connect style: ssh or pb"
322 self
.builderNames
= self
.getopt('builders', 'try_builders')
323 assert self
.builderNames
, "no builders! use --builder or " \
324 "try_builders=[names..] in .buildbot/options"
326 def getopt(self
, config_name
, options_name
, default
=None):
327 value
= self
.config
.get(config_name
)
328 if value
is None or value
== []:
329 value
= self
.opts
.get(options_name
)
330 if value
is None or value
== []:
335 # returns a Deferred which fires when the job parameters have been
338 # generate a random (unique) string. It would make sense to add a
339 # hostname and process ID here, but a) I suspect that would cause
340 # windows portability problems, and b) really this is good enough
341 self
.bsid
= "%d-%s" % (time
.time(), random
.randint(0, 1000000))
344 branch
= self
.getopt("branch", "try_branch")
346 difffile
= self
.config
.get("diff")
348 baserev
= self
.config
.get("baserev")
350 diff
= sys
.stdin
.read()
352 diff
= open(difffile
,"r").read()
353 patch
= (self
.config
['patchlevel'], diff
)
354 ss
= SourceStamp(branch
, baserev
, patch
)
355 d
= defer
.succeed(ss
)
357 vc
= self
.getopt("vc", "try_vc")
358 if vc
in ("cvs", "svn"):
359 # we need to find the tree-top
360 topdir
= self
.getopt("try_topdir", "try_topdir")
362 treedir
= os
.path
.expanduser(topdir
)
364 topfile
= self
.getopt("try-topfile", "try_topfile")
365 treedir
= getTopdir(topfile
)
367 treedir
= os
.getcwd()
368 d
= getSourceStamp(vc
, treedir
, branch
)
369 d
.addCallback(self
._createJob
_1)
372 def _createJob_1(self
, ss
):
373 self
.sourcestamp
= ss
374 if self
.connect
== "ssh":
375 patchlevel
, diff
= ss
.patch
376 revspec
= ss
.revision
379 self
.jobfile
= createJobfile(self
.bsid
,
380 ss
.branch
or "", revspec
,
384 def deliverJob(self
):
385 # returns a Deferred that fires when the job has been delivered
388 if self
.connect
== "ssh":
389 tryhost
= self
.getopt("tryhost", "try_host")
390 tryuser
= self
.getopt("username", "try_username")
391 trydir
= self
.getopt("trydir", "try_dir")
393 argv
= ["ssh", "-l", tryuser
, tryhost
,
394 "buildbot", "tryserver", "--jobdir", trydir
]
395 # now run this command and feed the contents of 'job' into stdin
397 pp
= RemoteTryPP(self
.jobfile
)
398 p
= reactor
.spawnProcess(pp
, argv
[0], argv
, os
.environ
)
401 if self
.connect
== "pb":
402 user
= self
.getopt("username", "try_username")
403 passwd
= self
.getopt("passwd", "try_password")
404 master
= self
.getopt("master", "try_master")
405 tryhost
, tryport
= master
.split(":")
406 tryport
= int(tryport
)
407 f
= pb
.PBClientFactory()
408 d
= f
.login(credentials
.UsernamePassword(user
, passwd
))
409 reactor
.connectTCP(tryhost
, tryport
, f
)
410 d
.addCallback(self
._deliverJob
_pb
)
412 raise RuntimeError("unknown connecttype '%s', should be 'ssh' or 'pb'"
415 def _deliverJob_pb(self
, remote
):
416 ss
= self
.sourcestamp
417 d
= remote
.callRemote("try",
418 ss
.branch
, ss
.revision
, ss
.patch
,
420 d
.addCallback(self
._deliverJob
_pb
2)
422 def _deliverJob_pb2(self
, status
):
423 self
.buildsetStatus
= status
427 # returns a Deferred that fires when the builds have finished, and
428 # may emit status messages while we wait
429 wait
= bool(self
.getopt("wait", "try_wait", False))
431 # TODO: emit the URL where they can follow the builds. This
432 # requires contacting the Status server over PB and doing
433 # getURLForThing() on the BuildSetStatus. To get URLs for
434 # individual builds would require we wait for the builds to
436 print "not waiting for builds to finish"
438 d
= self
.running
= defer
.Deferred()
439 if self
.buildsetStatus
:
441 # contact the status port
442 # we're probably using the ssh style
443 master
= self
.getopt("master", "masterstatus")
444 host
, port
= master
.split(":")
446 self
.announce("contacting the status port at %s:%d" % (host
, port
))
447 f
= pb
.PBClientFactory()
448 creds
= credentials
.UsernamePassword("statusClient", "clientpw")
450 reactor
.connectTCP(host
, port
, f
)
451 d
.addCallback(self
._getStatus
_ssh
_1)
454 def _getStatus_ssh_1(self
, remote
):
455 # find a remotereference to the corresponding BuildSetStatus object
456 self
.announce("waiting for job to be accepted")
457 g
= BuildSetStatusGrabber(remote
, self
.bsid
)
459 d
.addCallback(self
._getStatus
_1)
462 def _getStatus_1(self
, res
=None):
464 self
.buildsetStatus
= res
465 # gather the set of BuildRequests
466 d
= self
.buildsetStatus
.callRemote("getBuildRequests")
467 d
.addCallback(self
._getStatus
_2)
469 def _getStatus_2(self
, brs
):
470 self
.builderNames
= []
471 self
.buildRequests
= {}
473 # self.builds holds the current BuildStatus object for each one
476 # self.outstanding holds the list of builderNames which haven't
478 self
.outstanding
= []
480 # self.results holds the list of build results. It holds a tuple of
484 # self.currentStep holds the name of the Step that each build is
486 self
.currentStep
= {}
488 # self.ETA holds the expected finishing time (absolute time since
493 self
.builderNames
.append(n
)
494 self
.buildRequests
[n
] = br
495 self
.builds
[n
] = None
496 self
.outstanding
.append(n
)
497 self
.results
[n
] = [None,None]
498 self
.currentStep
[n
] = None
500 # get new Builds for this buildrequest. We follow each one until
501 # it finishes or is interrupted.
502 br
.callRemote("subscribe", self
)
504 # now that those queries are in transit, we can start the
505 # display-status-every-30-seconds loop
506 self
.printloop
= task
.LoopingCall(self
.printStatus
)
507 self
.printloop
.start(3, now
=False)
510 # these methods are invoked by the status objects we've subscribed to
512 def remote_newbuild(self
, bs
, builderName
):
513 if self
.builds
[builderName
]:
514 self
.builds
[builderName
].callRemote("unsubscribe", self
)
515 self
.builds
[builderName
] = bs
516 bs
.callRemote("subscribe", self
, 20)
517 d
= bs
.callRemote("waitUntilFinished")
518 d
.addCallback(self
._build
_finished
, builderName
)
520 def remote_stepStarted(self
, buildername
, build
, stepname
, step
):
521 self
.currentStep
[buildername
] = stepname
523 def remote_stepFinished(self
, buildername
, build
, stepname
, step
, results
):
526 def remote_buildETAUpdate(self
, buildername
, build
, eta
):
527 self
.ETA
[buildername
] = now() + eta
529 def _build_finished(self
, bs
, builderName
):
530 # we need to collect status from the newly-finished build. We don't
531 # remove the build from self.outstanding until we've collected
532 # everything we want.
533 self
.builds
[builderName
] = None
534 self
.ETA
[builderName
] = None
535 self
.currentStep
[builderName
] = "finished"
536 d
= bs
.callRemote("getResults")
537 d
.addCallback(self
._build
_finished
_2, bs
, builderName
)
539 def _build_finished_2(self
, results
, bs
, builderName
):
540 self
.results
[builderName
][0] = results
541 d
= bs
.callRemote("getText")
542 d
.addCallback(self
._build
_finished
_3, builderName
)
544 def _build_finished_3(self
, text
, builderName
):
545 self
.results
[builderName
][1] = text
547 self
.outstanding
.remove(builderName
)
548 if not self
.outstanding
:
550 return self
.statusDone()
552 def printStatus(self
):
553 names
= self
.buildRequests
.keys()
556 if n
not in self
.outstanding
:
557 # the build is finished, and we have results
558 code
,text
= self
.results
[n
]
559 t
= builder
.Results
[code
]
561 t
+= " (%s)" % " ".join(text
)
563 t
= self
.currentStep
[n
] or "building"
565 t
+= " [ETA %ds]" % (self
.ETA
[n
] - now())
568 self
.announce("%s: %s" % (n
, t
))
571 def statusDone(self
):
572 self
.printloop
.stop()
573 print "All Builds Complete"
574 # TODO: include a URL for all failing builds
575 names
= self
.buildRequests
.keys()
579 code
,text
= self
.results
[n
]
580 t
= "%s: %s" % (n
, builder
.Results
[code
])
582 t
+= " (%s)" % " ".join(text
)
584 if self
.results
[n
] != builder
.SUCCESS
:
591 self
.running
.callback(self
.exitcode
)
593 def announce(self
, message
):
598 # we can't do spawnProcess until we're inside reactor.run(), so get
600 print "using '%s' connect method" % self
.connect
603 d
.addCallback(lambda res
: self
.createJob())
604 d
.addCallback(lambda res
: self
.announce("job created"))
605 d
.addCallback(lambda res
: self
.deliverJob())
606 d
.addCallback(lambda res
: self
.announce("job has been delivered"))
607 d
.addCallback(lambda res
: self
.getStatus())
608 d
.addErrback(log
.err
)
609 d
.addCallback(self
.cleanup
)
610 d
.addCallback(lambda res
: reactor
.stop())
612 reactor
.callLater(0, d
.callback
, None)
614 sys
.exit(self
.exitcode
)
616 def logErr(self
, why
):
618 print "error during 'try' processing"
621 def cleanup(self
, res
=None):
622 if self
.buildsetStatus
:
623 self
.buildsetStatus
.broker
.transport
.loseConnection()