diff options
Diffstat (limited to 'buildbot/buildbot/status/builder.py')
-rw-r--r-- | buildbot/buildbot/status/builder.py | 2182 |
1 files changed, 2182 insertions, 0 deletions
diff --git a/buildbot/buildbot/status/builder.py b/buildbot/buildbot/status/builder.py new file mode 100644 index 0000000..97f356f --- /dev/null +++ b/buildbot/buildbot/status/builder.py @@ -0,0 +1,2182 @@ +# -*- test-case-name: buildbot.test.test_status -*- + +from zope.interface import implements +from twisted.python import log +from twisted.persisted import styles +from twisted.internet import reactor, defer, threads +from twisted.protocols import basic +from buildbot.process.properties import Properties + +import os, shutil, sys, re, urllib, itertools +from cPickle import load, dump +from cStringIO import StringIO +from bz2 import BZ2File + +# sibling imports +from buildbot import interfaces, util, sourcestamp + +SUCCESS, WARNINGS, FAILURE, SKIPPED, EXCEPTION = range(5) +Results = ["success", "warnings", "failure", "skipped", "exception"] + + +# build processes call the following methods: +# +# setDefaults +# +# currentlyBuilding +# currentlyIdle +# currentlyInterlocked +# currentlyOffline +# currentlyWaiting +# +# setCurrentActivity +# updateCurrentActivity +# addFileToCurrentActivity +# finishCurrentActivity +# +# startBuild +# finishBuild + +STDOUT = interfaces.LOG_CHANNEL_STDOUT +STDERR = interfaces.LOG_CHANNEL_STDERR +HEADER = interfaces.LOG_CHANNEL_HEADER +ChunkTypes = ["stdout", "stderr", "header"] + +class LogFileScanner(basic.NetstringReceiver): + def __init__(self, chunk_cb, channels=[]): + self.chunk_cb = chunk_cb + self.channels = channels + + def stringReceived(self, line): + channel = int(line[0]) + if not self.channels or (channel in self.channels): + self.chunk_cb((channel, line[1:])) + +class LogFileProducer: + """What's the plan? + + the LogFile has just one FD, used for both reading and writing. + Each time you add an entry, fd.seek to the end and then write. + + Each reader (i.e. Producer) keeps track of their own offset. The reader + starts by seeking to the start of the logfile, and reading forwards. + Between each hunk of file they yield chunks, so they must remember their + offset before yielding and re-seek back to that offset before reading + more data. When their read() returns EOF, they're finished with the first + phase of the reading (everything that's already been written to disk). + + After EOF, the remaining data is entirely in the current entries list. + These entries are all of the same channel, so we can do one "".join and + obtain a single chunk to be sent to the listener. But since that involves + a yield, and more data might arrive after we give up control, we have to + subscribe them before yielding. We can't subscribe them any earlier, + otherwise they'd get data out of order. + + We're using a generator in the first place so that the listener can + throttle us, which means they're pulling. But the subscription means + we're pushing. Really we're a Producer. In the first phase we can be + either a PullProducer or a PushProducer. In the second phase we're only a + PushProducer. + + So the client gives a LogFileConsumer to File.subscribeConsumer . This + Consumer must have registerProducer(), unregisterProducer(), and + writeChunk(), and is just like a regular twisted.interfaces.IConsumer, + except that writeChunk() takes chunks (tuples of (channel,text)) instead + of the normal write() which takes just text. The LogFileConsumer is + allowed to call stopProducing, pauseProducing, and resumeProducing on the + producer instance it is given. """ + + paused = False + subscribed = False + BUFFERSIZE = 2048 + + def __init__(self, logfile, consumer): + self.logfile = logfile + self.consumer = consumer + self.chunkGenerator = self.getChunks() + consumer.registerProducer(self, True) + + def getChunks(self): + f = self.logfile.getFile() + offset = 0 + chunks = [] + p = LogFileScanner(chunks.append) + f.seek(offset) + data = f.read(self.BUFFERSIZE) + offset = f.tell() + while data: + p.dataReceived(data) + while chunks: + c = chunks.pop(0) + yield c + f.seek(offset) + data = f.read(self.BUFFERSIZE) + offset = f.tell() + del f + + # now subscribe them to receive new entries + self.subscribed = True + self.logfile.watchers.append(self) + d = self.logfile.waitUntilFinished() + + # then give them the not-yet-merged data + if self.logfile.runEntries: + channel = self.logfile.runEntries[0][0] + text = "".join([c[1] for c in self.logfile.runEntries]) + yield (channel, text) + + # now we've caught up to the present. Anything further will come from + # the logfile subscription. We add the callback *after* yielding the + # data from runEntries, because the logfile might have finished + # during the yield. + d.addCallback(self.logfileFinished) + + def stopProducing(self): + # TODO: should we still call consumer.finish? probably not. + self.paused = True + self.consumer = None + self.done() + + def done(self): + if self.chunkGenerator: + self.chunkGenerator = None # stop making chunks + if self.subscribed: + self.logfile.watchers.remove(self) + self.subscribed = False + + def pauseProducing(self): + self.paused = True + + def resumeProducing(self): + # Twisted-1.3.0 has a bug which causes hangs when resumeProducing + # calls transport.write (there is a recursive loop, fixed in 2.0 in + # t.i.abstract.FileDescriptor.doWrite by setting the producerPaused + # flag *before* calling resumeProducing). To work around this, we + # just put off the real resumeProducing for a moment. This probably + # has a performance hit, but I'm going to assume that the log files + # are not retrieved frequently enough for it to be an issue. + + reactor.callLater(0, self._resumeProducing) + + def _resumeProducing(self): + self.paused = False + if not self.chunkGenerator: + return + try: + while not self.paused: + chunk = self.chunkGenerator.next() + self.consumer.writeChunk(chunk) + # we exit this when the consumer says to stop, or we run out + # of chunks + except StopIteration: + # if the generator finished, it will have done releaseFile + self.chunkGenerator = None + # now everything goes through the subscription, and they don't get to + # pause anymore + + def logChunk(self, build, step, logfile, channel, chunk): + if self.consumer: + self.consumer.writeChunk((channel, chunk)) + + def logfileFinished(self, logfile): + self.done() + if self.consumer: + self.consumer.unregisterProducer() + self.consumer.finish() + self.consumer = None + +def _tryremove(filename, timeout, retries): + """Try to remove a file, and if failed, try again in timeout. + Increases the timeout by a factor of 4, and only keeps trying for + another retries-amount of times. + + """ + try: + os.unlink(filename) + except OSError: + if retries > 0: + reactor.callLater(timeout, _tryremove, filename, timeout * 4, + retries - 1) + else: + log.msg("giving up on removing %s after over %d seconds" % + (filename, timeout)) + +class LogFile: + """A LogFile keeps all of its contents on disk, in a non-pickle format to + which new entries can easily be appended. The file on disk has a name + like 12-log-compile-output, under the Builder's directory. The actual + filename is generated (before the LogFile is created) by + L{BuildStatus.generateLogfileName}. + + Old LogFile pickles (which kept their contents in .entries) must be + upgraded. The L{BuilderStatus} is responsible for doing this, when it + loads the L{BuildStatus} into memory. The Build pickle is not modified, + so users who go from 0.6.5 back to 0.6.4 don't have to lose their + logs.""" + + implements(interfaces.IStatusLog, interfaces.ILogFile) + + finished = False + length = 0 + chunkSize = 10*1000 + runLength = 0 + runEntries = [] # provided so old pickled builds will getChunks() ok + entries = None + BUFFERSIZE = 2048 + filename = None # relative to the Builder's basedir + openfile = None + + def __init__(self, parent, name, logfilename): + """ + @type parent: L{BuildStepStatus} + @param parent: the Step that this log is a part of + @type name: string + @param name: the name of this log, typically 'output' + @type logfilename: string + @param logfilename: the Builder-relative pathname for the saved entries + """ + self.step = parent + self.name = name + self.filename = logfilename + fn = self.getFilename() + if os.path.exists(fn): + # the buildmaster was probably stopped abruptly, before the + # BuilderStatus could be saved, so BuilderStatus.nextBuildNumber + # is out of date, and we're overlapping with earlier builds now. + # Warn about it, but then overwrite the old pickle file + log.msg("Warning: Overwriting old serialized Build at %s" % fn) + self.openfile = open(fn, "w+") + self.runEntries = [] + self.watchers = [] + self.finishedWatchers = [] + + def getFilename(self): + return os.path.join(self.step.build.builder.basedir, self.filename) + + def hasContents(self): + return os.path.exists(self.getFilename() + '.bz2') or \ + os.path.exists(self.getFilename()) + + def getName(self): + return self.name + + def getStep(self): + return self.step + + def isFinished(self): + return self.finished + def waitUntilFinished(self): + if self.finished: + d = defer.succeed(self) + else: + d = defer.Deferred() + self.finishedWatchers.append(d) + return d + + def getFile(self): + if self.openfile: + # this is the filehandle we're using to write to the log, so + # don't close it! + return self.openfile + # otherwise they get their own read-only handle + # try a compressed log first + try: + return BZ2File(self.getFilename() + ".bz2", "r") + except IOError: + pass + return open(self.getFilename(), "r") + + def getText(self): + # this produces one ginormous string + return "".join(self.getChunks([STDOUT, STDERR], onlyText=True)) + + def getTextWithHeaders(self): + return "".join(self.getChunks(onlyText=True)) + + def getChunks(self, channels=[], onlyText=False): + # generate chunks for everything that was logged at the time we were + # first called, so remember how long the file was when we started. + # Don't read beyond that point. The current contents of + # self.runEntries will follow. + + # this returns an iterator, which means arbitrary things could happen + # while we're yielding. This will faithfully deliver the log as it + # existed when it was started, and not return anything after that + # point. To use this in subscribe(catchup=True) without missing any + # data, you must insure that nothing will be added to the log during + # yield() calls. + + f = self.getFile() + offset = 0 + f.seek(0, 2) + remaining = f.tell() + + leftover = None + if self.runEntries and (not channels or + (self.runEntries[0][0] in channels)): + leftover = (self.runEntries[0][0], + "".join([c[1] for c in self.runEntries])) + + # freeze the state of the LogFile by passing a lot of parameters into + # a generator + return self._generateChunks(f, offset, remaining, leftover, + channels, onlyText) + + def _generateChunks(self, f, offset, remaining, leftover, + channels, onlyText): + chunks = [] + p = LogFileScanner(chunks.append, channels) + f.seek(offset) + data = f.read(min(remaining, self.BUFFERSIZE)) + remaining -= len(data) + offset = f.tell() + while data: + p.dataReceived(data) + while chunks: + channel, text = chunks.pop(0) + if onlyText: + yield text + else: + yield (channel, text) + f.seek(offset) + data = f.read(min(remaining, self.BUFFERSIZE)) + remaining -= len(data) + offset = f.tell() + del f + + if leftover: + if onlyText: + yield leftover[1] + else: + yield leftover + + def readlines(self, channel=STDOUT): + """Return an iterator that produces newline-terminated lines, + excluding header chunks.""" + # TODO: make this memory-efficient, by turning it into a generator + # that retrieves chunks as necessary, like a pull-driven version of + # twisted.protocols.basic.LineReceiver + alltext = "".join(self.getChunks([channel], onlyText=True)) + io = StringIO(alltext) + return io.readlines() + + def subscribe(self, receiver, catchup): + if self.finished: + return + self.watchers.append(receiver) + if catchup: + for channel, text in self.getChunks(): + # TODO: add logChunks(), to send over everything at once? + receiver.logChunk(self.step.build, self.step, self, + channel, text) + + def unsubscribe(self, receiver): + if receiver in self.watchers: + self.watchers.remove(receiver) + + def subscribeConsumer(self, consumer): + p = LogFileProducer(self, consumer) + p.resumeProducing() + + # interface used by the build steps to add things to the log + + def merge(self): + # merge all .runEntries (which are all of the same type) into a + # single chunk for .entries + if not self.runEntries: + return + channel = self.runEntries[0][0] + text = "".join([c[1] for c in self.runEntries]) + assert channel < 10 + f = self.openfile + f.seek(0, 2) + offset = 0 + while offset < len(text): + size = min(len(text)-offset, self.chunkSize) + f.write("%d:%d" % (1 + size, channel)) + f.write(text[offset:offset+size]) + f.write(",") + offset += size + self.runEntries = [] + self.runLength = 0 + + def addEntry(self, channel, text): + assert not self.finished + # we only add to .runEntries here. merge() is responsible for adding + # merged chunks to .entries + if self.runEntries and channel != self.runEntries[0][0]: + self.merge() + self.runEntries.append((channel, text)) + self.runLength += len(text) + if self.runLength >= self.chunkSize: + self.merge() + + for w in self.watchers: + w.logChunk(self.step.build, self.step, self, channel, text) + self.length += len(text) + + def addStdout(self, text): + self.addEntry(STDOUT, text) + def addStderr(self, text): + self.addEntry(STDERR, text) + def addHeader(self, text): + self.addEntry(HEADER, text) + + def finish(self): + self.merge() + if self.openfile: + # we don't do an explicit close, because there might be readers + # shareing the filehandle. As soon as they stop reading, the + # filehandle will be released and automatically closed. We will + # do a sync, however, to make sure the log gets saved in case of + # a crash. + self.openfile.flush() + os.fsync(self.openfile.fileno()) + del self.openfile + self.finished = True + watchers = self.finishedWatchers + self.finishedWatchers = [] + for w in watchers: + w.callback(self) + self.watchers = [] + + + def compressLog(self): + compressed = self.getFilename() + ".bz2.tmp" + d = threads.deferToThread(self._compressLog, compressed) + d.addCallback(self._renameCompressedLog, compressed) + d.addErrback(self._cleanupFailedCompress, compressed) + return d + + def _compressLog(self, compressed): + infile = self.getFile() + cf = BZ2File(compressed, 'w') + bufsize = 1024*1024 + while True: + buf = infile.read(bufsize) + cf.write(buf) + if len(buf) < bufsize: + break + cf.close() + def _renameCompressedLog(self, rv, compressed): + filename = self.getFilename() + '.bz2' + if sys.platform == 'win32': + # windows cannot rename a file on top of an existing one, so + # fall back to delete-first. There are ways this can fail and + # lose the builder's history, so we avoid using it in the + # general (non-windows) case + if os.path.exists(filename): + os.unlink(filename) + os.rename(compressed, filename) + _tryremove(self.getFilename(), 1, 5) + def _cleanupFailedCompress(self, failure, compressed): + log.msg("failed to compress %s" % self.getFilename()) + if os.path.exists(compressed): + _tryremove(compressed, 1, 5) + failure.trap() # reraise the failure + + # persistence stuff + def __getstate__(self): + d = self.__dict__.copy() + del d['step'] # filled in upon unpickling + del d['watchers'] + del d['finishedWatchers'] + d['entries'] = [] # let 0.6.4 tolerate the saved log. TODO: really? + if d.has_key('finished'): + del d['finished'] + if d.has_key('openfile'): + del d['openfile'] + return d + + def __setstate__(self, d): + self.__dict__ = d + self.watchers = [] # probably not necessary + self.finishedWatchers = [] # same + # self.step must be filled in by our parent + self.finished = True + + def upgrade(self, logfilename): + """Save our .entries to a new-style offline log file (if necessary), + and modify our in-memory representation to use it. The original + pickled LogFile (inside the pickled Build) won't be modified.""" + self.filename = logfilename + if not os.path.exists(self.getFilename()): + self.openfile = open(self.getFilename(), "w") + self.finished = False + for channel,text in self.entries: + self.addEntry(channel, text) + self.finish() # releases self.openfile, which will be closed + del self.entries + +class HTMLLogFile: + implements(interfaces.IStatusLog) + + filename = None + + def __init__(self, parent, name, logfilename, html): + self.step = parent + self.name = name + self.filename = logfilename + self.html = html + + def getName(self): + return self.name # set in BuildStepStatus.addLog + def getStep(self): + return self.step + + def isFinished(self): + return True + def waitUntilFinished(self): + return defer.succeed(self) + + def hasContents(self): + return True + def getText(self): + return self.html # looks kinda like text + def getTextWithHeaders(self): + return self.html + def getChunks(self): + return [(STDERR, self.html)] + + def subscribe(self, receiver, catchup): + pass + def unsubscribe(self, receiver): + pass + + def finish(self): + pass + + def __getstate__(self): + d = self.__dict__.copy() + del d['step'] + return d + + def upgrade(self, logfilename): + pass + + +class Event: + implements(interfaces.IStatusEvent) + + started = None + finished = None + text = [] + + # IStatusEvent methods + def getTimes(self): + return (self.started, self.finished) + def getText(self): + return self.text + def getLogs(self): + return [] + + def finish(self): + self.finished = util.now() + +class TestResult: + implements(interfaces.ITestResult) + + def __init__(self, name, results, text, logs): + assert isinstance(name, tuple) + self.name = name + self.results = results + self.text = text + self.logs = logs + + def getName(self): + return self.name + + def getResults(self): + return self.results + + def getText(self): + return self.text + + def getLogs(self): + return self.logs + + +class BuildSetStatus: + implements(interfaces.IBuildSetStatus) + + def __init__(self, source, reason, builderNames, bsid=None): + self.source = source + self.reason = reason + self.builderNames = builderNames + self.id = bsid + self.successWatchers = [] + self.finishedWatchers = [] + self.stillHopeful = True + self.finished = False + + def setBuildRequestStatuses(self, buildRequestStatuses): + self.buildRequests = buildRequestStatuses + def setResults(self, results): + # the build set succeeds only if all its component builds succeed + self.results = results + def giveUpHope(self): + self.stillHopeful = False + + + def notifySuccessWatchers(self): + for d in self.successWatchers: + d.callback(self) + self.successWatchers = [] + + def notifyFinishedWatchers(self): + self.finished = True + for d in self.finishedWatchers: + d.callback(self) + self.finishedWatchers = [] + + # methods for our clients + + def getSourceStamp(self): + return self.source + def getReason(self): + return self.reason + def getResults(self): + return self.results + def getID(self): + return self.id + + def getBuilderNames(self): + return self.builderNames + def getBuildRequests(self): + return self.buildRequests + def isFinished(self): + return self.finished + + def waitUntilSuccess(self): + if self.finished or not self.stillHopeful: + # the deferreds have already fired + return defer.succeed(self) + d = defer.Deferred() + self.successWatchers.append(d) + return d + + def waitUntilFinished(self): + if self.finished: + return defer.succeed(self) + d = defer.Deferred() + self.finishedWatchers.append(d) + return d + +class BuildRequestStatus: + implements(interfaces.IBuildRequestStatus) + + def __init__(self, source, builderName): + self.source = source + self.builderName = builderName + self.builds = [] # list of BuildStatus objects + self.observers = [] + self.submittedAt = None + + def buildStarted(self, build): + self.builds.append(build) + for o in self.observers[:]: + o(build) + + # methods called by our clients + def getSourceStamp(self): + return self.source + def getBuilderName(self): + return self.builderName + def getBuilds(self): + return self.builds + + def subscribe(self, observer): + self.observers.append(observer) + for b in self.builds: + observer(b) + def unsubscribe(self, observer): + self.observers.remove(observer) + + def getSubmitTime(self): + return self.submittedAt + def setSubmitTime(self, t): + self.submittedAt = t + + +class BuildStepStatus(styles.Versioned): + """ + I represent a collection of output status for a + L{buildbot.process.step.BuildStep}. + + Statistics contain any information gleaned from a step that is + not in the form of a logfile. As an example, steps that run + tests might gather statistics about the number of passed, failed, + or skipped tests. + + @type progress: L{buildbot.status.progress.StepProgress} + @cvar progress: tracks ETA for the step + @type text: list of strings + @cvar text: list of short texts that describe the command and its status + @type text2: list of strings + @cvar text2: list of short texts added to the overall build description + @type logs: dict of string -> L{buildbot.status.builder.LogFile} + @ivar logs: logs of steps + @type statistics: dict + @ivar statistics: results from running this step + """ + # note that these are created when the Build is set up, before each + # corresponding BuildStep has started. + implements(interfaces.IBuildStepStatus, interfaces.IStatusEvent) + persistenceVersion = 2 + + started = None + finished = None + progress = None + text = [] + results = (None, []) + text2 = [] + watchers = [] + updates = {} + finishedWatchers = [] + statistics = {} + + def __init__(self, parent): + assert interfaces.IBuildStatus(parent) + self.build = parent + self.logs = [] + self.urls = {} + self.watchers = [] + self.updates = {} + self.finishedWatchers = [] + self.statistics = {} + + def getName(self): + """Returns a short string with the name of this step. This string + may have spaces in it.""" + return self.name + + def getBuild(self): + return self.build + + def getTimes(self): + return (self.started, self.finished) + + def getExpectations(self): + """Returns a list of tuples (name, current, target).""" + if not self.progress: + return [] + ret = [] + metrics = self.progress.progress.keys() + metrics.sort() + for m in metrics: + t = (m, self.progress.progress[m], self.progress.expectations[m]) + ret.append(t) + return ret + + def getLogs(self): + return self.logs + + def getURLs(self): + return self.urls.copy() + + def isFinished(self): + return (self.finished is not None) + + def waitUntilFinished(self): + if self.finished: + d = defer.succeed(self) + else: + d = defer.Deferred() + self.finishedWatchers.append(d) + return d + + # while the step is running, the following methods make sense. + # Afterwards they return None + + def getETA(self): + if self.started is None: + return None # not started yet + if self.finished is not None: + return None # already finished + if not self.progress: + return None # no way to predict + return self.progress.remaining() + + # Once you know the step has finished, the following methods are legal. + # Before this step has finished, they all return None. + + def getText(self): + """Returns a list of strings which describe the step. These are + intended to be displayed in a narrow column. If more space is + available, the caller should join them together with spaces before + presenting them to the user.""" + return self.text + + def getResults(self): + """Return a tuple describing the results of the step. + 'result' is one of the constants in L{buildbot.status.builder}: + SUCCESS, WARNINGS, FAILURE, or SKIPPED. + 'strings' is an optional list of strings that the step wants to + append to the overall build's results. These strings are usually + more terse than the ones returned by getText(): in particular, + successful Steps do not usually contribute any text to the + overall build. + + @rtype: tuple of int, list of strings + @returns: (result, strings) + """ + return (self.results, self.text2) + + def hasStatistic(self, name): + """Return true if this step has a value for the given statistic. + """ + return self.statistics.has_key(name) + + def getStatistic(self, name, default=None): + """Return the given statistic, if present + """ + return self.statistics.get(name, default) + + # subscription interface + + def subscribe(self, receiver, updateInterval=10): + # will get logStarted, logFinished, stepETAUpdate + assert receiver not in self.watchers + self.watchers.append(receiver) + self.sendETAUpdate(receiver, updateInterval) + + def sendETAUpdate(self, receiver, updateInterval): + self.updates[receiver] = None + # they might unsubscribe during stepETAUpdate + receiver.stepETAUpdate(self.build, self, + self.getETA(), self.getExpectations()) + if receiver in self.watchers: + self.updates[receiver] = reactor.callLater(updateInterval, + self.sendETAUpdate, + receiver, + updateInterval) + + def unsubscribe(self, receiver): + if receiver in self.watchers: + self.watchers.remove(receiver) + if receiver in self.updates: + if self.updates[receiver] is not None: + self.updates[receiver].cancel() + del self.updates[receiver] + + + # methods to be invoked by the BuildStep + + def setName(self, stepname): + self.name = stepname + + def setColor(self, color): + log.msg("BuildStepStatus.setColor is no longer supported -- ignoring color %s" % (color,)) + + def setProgress(self, stepprogress): + self.progress = stepprogress + + def stepStarted(self): + self.started = util.now() + if self.build: + self.build.stepStarted(self) + + def addLog(self, name): + assert self.started # addLog before stepStarted won't notify watchers + logfilename = self.build.generateLogfileName(self.name, name) + log = LogFile(self, name, logfilename) + self.logs.append(log) + for w in self.watchers: + receiver = w.logStarted(self.build, self, log) + if receiver: + log.subscribe(receiver, True) + d = log.waitUntilFinished() + d.addCallback(lambda log: log.unsubscribe(receiver)) + d = log.waitUntilFinished() + d.addCallback(self.logFinished) + return log + + def addHTMLLog(self, name, html): + assert self.started # addLog before stepStarted won't notify watchers + logfilename = self.build.generateLogfileName(self.name, name) + log = HTMLLogFile(self, name, logfilename, html) + self.logs.append(log) + for w in self.watchers: + receiver = w.logStarted(self.build, self, log) + # TODO: think about this: there isn't much point in letting + # them subscribe + #if receiver: + # log.subscribe(receiver, True) + w.logFinished(self.build, self, log) + + def logFinished(self, log): + for w in self.watchers: + w.logFinished(self.build, self, log) + + def addURL(self, name, url): + self.urls[name] = url + + def setText(self, text): + self.text = text + for w in self.watchers: + w.stepTextChanged(self.build, self, text) + def setText2(self, text): + self.text2 = text + for w in self.watchers: + w.stepText2Changed(self.build, self, text) + + def setStatistic(self, name, value): + """Set the given statistic. Usually called by subclasses. + """ + self.statistics[name] = value + + def stepFinished(self, results): + self.finished = util.now() + self.results = results + cld = [] # deferreds for log compression + logCompressionLimit = self.build.builder.logCompressionLimit + for loog in self.logs: + if not loog.isFinished(): + loog.finish() + # if log compression is on, and it's a real LogFile, + # HTMLLogFiles aren't files + if logCompressionLimit is not False and \ + isinstance(loog, LogFile): + if os.path.getsize(loog.getFilename()) > logCompressionLimit: + cld.append(loog.compressLog()) + + for r in self.updates.keys(): + if self.updates[r] is not None: + self.updates[r].cancel() + del self.updates[r] + + watchers = self.finishedWatchers + self.finishedWatchers = [] + for w in watchers: + w.callback(self) + if cld: + return defer.DeferredList(cld) + + # persistence + + def __getstate__(self): + d = styles.Versioned.__getstate__(self) + del d['build'] # filled in when loading + if d.has_key('progress'): + del d['progress'] + del d['watchers'] + del d['finishedWatchers'] + del d['updates'] + return d + + def __setstate__(self, d): + styles.Versioned.__setstate__(self, d) + # self.build must be filled in by our parent + for loog in self.logs: + loog.step = self + + def upgradeToVersion1(self): + if not hasattr(self, "urls"): + self.urls = {} + + def upgradeToVersion2(self): + if not hasattr(self, "statistics"): + self.statistics = {} + + +class BuildStatus(styles.Versioned): + implements(interfaces.IBuildStatus, interfaces.IStatusEvent) + persistenceVersion = 3 + + source = None + reason = None + changes = [] + blamelist = [] + requests = [] + progress = None + started = None + finished = None + currentStep = None + text = [] + results = None + slavename = "???" + + # these lists/dicts are defined here so that unserialized instances have + # (empty) values. They are set in __init__ to new objects to make sure + # each instance gets its own copy. + watchers = [] + updates = {} + finishedWatchers = [] + testResults = {} + + def __init__(self, parent, number): + """ + @type parent: L{BuilderStatus} + @type number: int + """ + assert interfaces.IBuilderStatus(parent) + self.builder = parent + self.number = number + self.watchers = [] + self.updates = {} + self.finishedWatchers = [] + self.steps = [] + self.testResults = {} + self.properties = Properties() + self.requests = [] + + # IBuildStatus + + def getBuilder(self): + """ + @rtype: L{BuilderStatus} + """ + return self.builder + + def getProperty(self, propname): + return self.properties[propname] + + def getProperties(self): + return self.properties + + def getNumber(self): + return self.number + + def getPreviousBuild(self): + if self.number == 0: + return None + return self.builder.getBuild(self.number-1) + + def getSourceStamp(self, absolute=False): + if not absolute or not self.properties.has_key('got_revision'): + return self.source + return self.source.getAbsoluteSourceStamp(self.properties['got_revision']) + + def getReason(self): + return self.reason + + def getChanges(self): + return self.changes + + def getRequests(self): + return self.requests + + def getResponsibleUsers(self): + return self.blamelist + + def getInterestedUsers(self): + # TODO: the Builder should add others: sheriffs, domain-owners + return self.blamelist + self.properties.getProperty('owners', []) + + def getSteps(self): + """Return a list of IBuildStepStatus objects. For invariant builds + (those which always use the same set of Steps), this should be the + complete list, however some of the steps may not have started yet + (step.getTimes()[0] will be None). For variant builds, this may not + be complete (asking again later may give you more of them).""" + return self.steps + + def getTimes(self): + return (self.started, self.finished) + + _sentinel = [] # used as a sentinel to indicate unspecified initial_value + def getSummaryStatistic(self, name, summary_fn, initial_value=_sentinel): + """Summarize the named statistic over all steps in which it + exists, using combination_fn and initial_value to combine multiple + results into a single result. This translates to a call to Python's + X{reduce}:: + return reduce(summary_fn, step_stats_list, initial_value) + """ + step_stats_list = [ + st.getStatistic(name) + for st in self.steps + if st.hasStatistic(name) ] + if initial_value is self._sentinel: + return reduce(summary_fn, step_stats_list) + else: + return reduce(summary_fn, step_stats_list, initial_value) + + def isFinished(self): + return (self.finished is not None) + + def waitUntilFinished(self): + if self.finished: + d = defer.succeed(self) + else: + d = defer.Deferred() + self.finishedWatchers.append(d) + return d + + # while the build is running, the following methods make sense. + # Afterwards they return None + + def getETA(self): + if self.finished is not None: + return None + if not self.progress: + return None + eta = self.progress.eta() + if eta is None: + return None + return eta - util.now() + + def getCurrentStep(self): + return self.currentStep + + # Once you know the build has finished, the following methods are legal. + # Before ths build has finished, they all return None. + + def getText(self): + text = [] + text.extend(self.text) + for s in self.steps: + text.extend(s.text2) + return text + + def getResults(self): + return self.results + + def getSlavename(self): + return self.slavename + + def getTestResults(self): + return self.testResults + + def getLogs(self): + # TODO: steps should contribute significant logs instead of this + # hack, which returns every log from every step. The logs should get + # names like "compile" and "test" instead of "compile.output" + logs = [] + for s in self.steps: + for log in s.getLogs(): + logs.append(log) + return logs + + # subscription interface + + def subscribe(self, receiver, updateInterval=None): + # will receive stepStarted and stepFinished messages + # and maybe buildETAUpdate + self.watchers.append(receiver) + if updateInterval is not None: + self.sendETAUpdate(receiver, updateInterval) + + def sendETAUpdate(self, receiver, updateInterval): + self.updates[receiver] = None + ETA = self.getETA() + if ETA is not None: + receiver.buildETAUpdate(self, self.getETA()) + # they might have unsubscribed during buildETAUpdate + if receiver in self.watchers: + self.updates[receiver] = reactor.callLater(updateInterval, + self.sendETAUpdate, + receiver, + updateInterval) + + def unsubscribe(self, receiver): + if receiver in self.watchers: + self.watchers.remove(receiver) + if receiver in self.updates: + if self.updates[receiver] is not None: + self.updates[receiver].cancel() + del self.updates[receiver] + + # methods for the base.Build to invoke + + def addStepWithName(self, name): + """The Build is setting up, and has added a new BuildStep to its + list. Create a BuildStepStatus object to which it can send status + updates.""" + + s = BuildStepStatus(self) + s.setName(name) + self.steps.append(s) + return s + + def setProperty(self, propname, value, source): + self.properties.setProperty(propname, value, source) + + def addTestResult(self, result): + self.testResults[result.getName()] = result + + def setSourceStamp(self, sourceStamp): + self.source = sourceStamp + self.changes = self.source.changes + + def setRequests(self, requests): + self.requests = requests + + def setReason(self, reason): + self.reason = reason + def setBlamelist(self, blamelist): + self.blamelist = blamelist + def setProgress(self, progress): + self.progress = progress + + def buildStarted(self, build): + """The Build has been set up and is about to be started. It can now + be safely queried, so it is time to announce the new build.""" + + self.started = util.now() + # now that we're ready to report status, let the BuilderStatus tell + # the world about us + self.builder.buildStarted(self) + + def setSlavename(self, slavename): + self.slavename = slavename + + def setText(self, text): + assert isinstance(text, (list, tuple)) + self.text = text + def setResults(self, results): + self.results = results + + def buildFinished(self): + self.currentStep = None + self.finished = util.now() + + for r in self.updates.keys(): + if self.updates[r] is not None: + self.updates[r].cancel() + del self.updates[r] + + watchers = self.finishedWatchers + self.finishedWatchers = [] + for w in watchers: + w.callback(self) + + # methods called by our BuildStepStatus children + + def stepStarted(self, step): + self.currentStep = step + name = self.getBuilder().getName() + for w in self.watchers: + receiver = w.stepStarted(self, step) + if receiver: + if type(receiver) == type(()): + step.subscribe(receiver[0], receiver[1]) + else: + step.subscribe(receiver) + d = step.waitUntilFinished() + d.addCallback(lambda step: step.unsubscribe(receiver)) + + step.waitUntilFinished().addCallback(self._stepFinished) + + def _stepFinished(self, step): + results = step.getResults() + for w in self.watchers: + w.stepFinished(self, step, results) + + # methods called by our BuilderStatus parent + + def pruneLogs(self): + # this build is somewhat old: remove the build logs to save space + # TODO: delete logs visible through IBuildStatus.getLogs + for s in self.steps: + s.pruneLogs() + + def pruneSteps(self): + # this build is very old: remove the build steps too + self.steps = [] + + # persistence stuff + + def generateLogfileName(self, stepname, logname): + """Return a filename (relative to the Builder's base directory) where + the logfile's contents can be stored uniquely. + + The base filename is made by combining our build number, the Step's + name, and the log's name, then removing unsuitable characters. The + filename is then made unique by appending _0, _1, etc, until it does + not collide with any other logfile. + + These files are kept in the Builder's basedir (rather than a + per-Build subdirectory) because that makes cleanup easier: cron and + find will help get rid of the old logs, but the empty directories are + more of a hassle to remove.""" + + starting_filename = "%d-log-%s-%s" % (self.number, stepname, logname) + starting_filename = re.sub(r'[^\w\.\-]', '_', starting_filename) + # now make it unique + unique_counter = 0 + filename = starting_filename + while filename in [l.filename + for step in self.steps + for l in step.getLogs() + if l.filename]: + filename = "%s_%d" % (starting_filename, unique_counter) + unique_counter += 1 + return filename + + def __getstate__(self): + d = styles.Versioned.__getstate__(self) + # for now, a serialized Build is always "finished". We will never + # save unfinished builds. + if not self.finished: + d['finished'] = True + # TODO: push an "interrupted" step so it is clear that the build + # was interrupted. The builder will have a 'shutdown' event, but + # someone looking at just this build will be confused as to why + # the last log is truncated. + del d['builder'] # filled in by our parent when loading + del d['watchers'] + del d['updates'] + del d['requests'] + del d['finishedWatchers'] + return d + + def __setstate__(self, d): + styles.Versioned.__setstate__(self, d) + # self.builder must be filled in by our parent when loading + for step in self.steps: + step.build = self + self.watchers = [] + self.updates = {} + self.finishedWatchers = [] + + def upgradeToVersion1(self): + if hasattr(self, "sourceStamp"): + # the old .sourceStamp attribute wasn't actually very useful + maxChangeNumber, patch = self.sourceStamp + changes = getattr(self, 'changes', []) + source = sourcestamp.SourceStamp(branch=None, + revision=None, + patch=patch, + changes=changes) + self.source = source + self.changes = source.changes + del self.sourceStamp + + def upgradeToVersion2(self): + self.properties = {} + + def upgradeToVersion3(self): + # in version 3, self.properties became a Properties object + propdict = self.properties + self.properties = Properties() + self.properties.update(propdict, "Upgrade from previous version") + + def upgradeLogfiles(self): + # upgrade any LogFiles that need it. This must occur after we've been + # attached to our Builder, and after we know about all LogFiles of + # all Steps (to get the filenames right). + assert self.builder + for s in self.steps: + for l in s.getLogs(): + if l.filename: + pass # new-style, log contents are on disk + else: + logfilename = self.generateLogfileName(s.name, l.name) + # let the logfile update its .filename pointer, + # transferring its contents onto disk if necessary + l.upgrade(logfilename) + + def saveYourself(self): + filename = os.path.join(self.builder.basedir, "%d" % self.number) + if os.path.isdir(filename): + # leftover from 0.5.0, which stored builds in directories + shutil.rmtree(filename, ignore_errors=True) + tmpfilename = filename + ".tmp" + try: + dump(self, open(tmpfilename, "wb"), -1) + if sys.platform == 'win32': + # windows cannot rename a file on top of an existing one, so + # fall back to delete-first. There are ways this can fail and + # lose the builder's history, so we avoid using it in the + # general (non-windows) case + if os.path.exists(filename): + os.unlink(filename) + os.rename(tmpfilename, filename) + except: + log.msg("unable to save build %s-#%d" % (self.builder.name, + self.number)) + log.err() + + + +class BuilderStatus(styles.Versioned): + """I handle status information for a single process.base.Builder object. + That object sends status changes to me (frequently as Events), and I + provide them on demand to the various status recipients, like the HTML + waterfall display and the live status clients. It also sends build + summaries to me, which I log and provide to status clients who aren't + interested in seeing details of the individual build steps. + + I am responsible for maintaining the list of historic Events and Builds, + pruning old ones, and loading them from / saving them to disk. + + I live in the buildbot.process.base.Builder object, in the + .builder_status attribute. + + @type category: string + @ivar category: user-defined category this builder belongs to; can be + used to filter on in status clients + """ + + implements(interfaces.IBuilderStatus, interfaces.IEventSource) + persistenceVersion = 1 + + # these limit the amount of memory we consume, as well as the size of the + # main Builder pickle. The Build and LogFile pickles on disk must be + # handled separately. + buildCacheSize = 30 + buildHorizon = 100 # forget builds beyond this + stepHorizon = 50 # forget steps in builds beyond this + + category = None + currentBigState = "offline" # or idle/waiting/interlocked/building + basedir = None # filled in by our parent + + def __init__(self, buildername, category=None): + self.name = buildername + self.category = category + + self.slavenames = [] + self.events = [] + # these three hold Events, and are used to retrieve the current + # state of the boxes. + self.lastBuildStatus = None + #self.currentBig = None + #self.currentSmall = None + self.currentBuilds = [] + self.pendingBuilds = [] + self.nextBuild = None + self.watchers = [] + self.buildCache = [] # TODO: age builds out of the cache + self.logCompressionLimit = False # default to no compression for tests + + # persistence + + def __getstate__(self): + # when saving, don't record transient stuff like what builds are + # currently running, because they won't be there when we start back + # up. Nor do we save self.watchers, nor anything that gets set by our + # parent like .basedir and .status + d = styles.Versioned.__getstate__(self) + d['watchers'] = [] + del d['buildCache'] + for b in self.currentBuilds: + b.saveYourself() + # TODO: push a 'hey, build was interrupted' event + del d['currentBuilds'] + del d['pendingBuilds'] + del d['currentBigState'] + del d['basedir'] + del d['status'] + del d['nextBuildNumber'] + return d + + def __setstate__(self, d): + # when loading, re-initialize the transient stuff. Remember that + # upgradeToVersion1 and such will be called after this finishes. + styles.Versioned.__setstate__(self, d) + self.buildCache = [] + self.currentBuilds = [] + self.pendingBuilds = [] + self.watchers = [] + self.slavenames = [] + # self.basedir must be filled in by our parent + # self.status must be filled in by our parent + + def upgradeToVersion1(self): + if hasattr(self, 'slavename'): + self.slavenames = [self.slavename] + del self.slavename + if hasattr(self, 'nextBuildNumber'): + del self.nextBuildNumber # determineNextBuildNumber chooses this + + def determineNextBuildNumber(self): + """Scan our directory of saved BuildStatus instances to determine + what our self.nextBuildNumber should be. Set it one larger than the + highest-numbered build we discover. This is called by the top-level + Status object shortly after we are created or loaded from disk. + """ + existing_builds = [int(f) + for f in os.listdir(self.basedir) + if re.match("^\d+$", f)] + if existing_builds: + self.nextBuildNumber = max(existing_builds) + 1 + else: + self.nextBuildNumber = 0 + + def setLogCompressionLimit(self, lowerLimit): + self.logCompressionLimit = lowerLimit + + def saveYourself(self): + for b in self.buildCache: + if not b.isFinished: + # interrupted build, need to save it anyway. + # BuildStatus.saveYourself will mark it as interrupted. + b.saveYourself() + filename = os.path.join(self.basedir, "builder") + tmpfilename = filename + ".tmp" + try: + dump(self, open(tmpfilename, "wb"), -1) + if sys.platform == 'win32': + # windows cannot rename a file on top of an existing one + if os.path.exists(filename): + os.unlink(filename) + os.rename(tmpfilename, filename) + except: + log.msg("unable to save builder %s" % self.name) + log.err() + + + # build cache management + + def addBuildToCache(self, build): + if build in self.buildCache: + return + self.buildCache.append(build) + while len(self.buildCache) > self.buildCacheSize: + self.buildCache.pop(0) + + def getBuildByNumber(self, number): + for b in self.currentBuilds: + if b.number == number: + return b + for build in self.buildCache: + if build.number == number: + return build + filename = os.path.join(self.basedir, "%d" % number) + try: + build = load(open(filename, "rb")) + styles.doUpgrade() + build.builder = self + # handle LogFiles from after 0.5.0 and before 0.6.5 + build.upgradeLogfiles() + self.addBuildToCache(build) + return build + except IOError: + raise IndexError("no such build %d" % number) + except EOFError: + raise IndexError("corrupted build pickle %d" % number) + + def prune(self): + return # TODO: change this to walk through the filesystem + # first, blow away all builds beyond our build horizon + self.builds = self.builds[-self.buildHorizon:] + # then prune steps in builds past the step horizon + for b in self.builds[0:-self.stepHorizon]: + b.pruneSteps() + + # IBuilderStatus methods + def getName(self): + return self.name + + def getState(self): + return (self.currentBigState, self.currentBuilds) + + def getSlaves(self): + return [self.status.getSlave(name) for name in self.slavenames] + + def getPendingBuilds(self): + return self.pendingBuilds + + def getCurrentBuilds(self): + return self.currentBuilds + + def getLastFinishedBuild(self): + b = self.getBuild(-1) + if not (b and b.isFinished()): + b = self.getBuild(-2) + return b + + def getBuild(self, number): + if number < 0: + number = self.nextBuildNumber + number + if number < 0 or number >= self.nextBuildNumber: + return None + + try: + return self.getBuildByNumber(number) + except IndexError: + return None + + def getEvent(self, number): + try: + return self.events[number] + except IndexError: + return None + + def generateFinishedBuilds(self, branches=[], + num_builds=None, + max_buildnum=None, + finished_before=None, + max_search=200): + got = 0 + for Nb in itertools.count(1): + if Nb > self.nextBuildNumber: + break + if Nb > max_search: + break + build = self.getBuild(-Nb) + if build is None: + continue + if max_buildnum is not None: + if build.getNumber() > max_buildnum: + continue + if not build.isFinished(): + continue + if finished_before is not None: + start, end = build.getTimes() + if end >= finished_before: + continue + if branches: + if build.getSourceStamp().branch not in branches: + continue + got += 1 + yield build + if num_builds is not None: + if got >= num_builds: + return + + def eventGenerator(self, branches=[]): + """This function creates a generator which will provide all of this + Builder's status events, starting with the most recent and + progressing backwards in time. """ + + # remember the oldest-to-earliest flow here. "next" means earlier. + + # TODO: interleave build steps and self.events by timestamp. + # TODO: um, I think we're already doing that. + + # TODO: there's probably something clever we could do here to + # interleave two event streams (one from self.getBuild and the other + # from self.getEvent), which would be simpler than this control flow + + eventIndex = -1 + e = self.getEvent(eventIndex) + for Nb in range(1, self.nextBuildNumber+1): + b = self.getBuild(-Nb) + if not b: + break + if branches and not b.getSourceStamp().branch in branches: + continue + steps = b.getSteps() + for Ns in range(1, len(steps)+1): + if steps[-Ns].started: + step_start = steps[-Ns].getTimes()[0] + while e is not None and e.getTimes()[0] > step_start: + yield e + eventIndex -= 1 + e = self.getEvent(eventIndex) + yield steps[-Ns] + yield b + while e is not None: + yield e + eventIndex -= 1 + e = self.getEvent(eventIndex) + + def subscribe(self, receiver): + # will get builderChangedState, buildStarted, and buildFinished + self.watchers.append(receiver) + self.publishState(receiver) + + def unsubscribe(self, receiver): + self.watchers.remove(receiver) + + ## Builder interface (methods called by the Builder which feeds us) + + def setSlavenames(self, names): + self.slavenames = names + + def addEvent(self, text=[]): + # this adds a duration event. When it is done, the user should call + # e.finish(). They can also mangle it by modifying .text + e = Event() + e.started = util.now() + e.text = text + self.events.append(e) + return e # they are free to mangle it further + + def addPointEvent(self, text=[]): + # this adds a point event, one which occurs as a single atomic + # instant of time. + e = Event() + e.started = util.now() + e.finished = 0 + e.text = text + self.events.append(e) + return e # for consistency, but they really shouldn't touch it + + def setBigState(self, state): + needToUpdate = state != self.currentBigState + self.currentBigState = state + if needToUpdate: + self.publishState() + + def publishState(self, target=None): + state = self.currentBigState + + if target is not None: + # unicast + target.builderChangedState(self.name, state) + return + for w in self.watchers: + try: + w.builderChangedState(self.name, state) + except: + log.msg("Exception caught publishing state to %r" % w) + log.err() + + def newBuild(self): + """The Builder has decided to start a build, but the Build object is + not yet ready to report status (it has not finished creating the + Steps). Create a BuildStatus object that it can use.""" + number = self.nextBuildNumber + self.nextBuildNumber += 1 + # TODO: self.saveYourself(), to make sure we don't forget about the + # build number we've just allocated. This is not quite as important + # as it was before we switch to determineNextBuildNumber, but I think + # it may still be useful to have the new build save itself. + s = BuildStatus(self, number) + s.waitUntilFinished().addCallback(self._buildFinished) + return s + + def addBuildRequest(self, brstatus): + self.pendingBuilds.append(brstatus) + for w in self.watchers: + w.requestSubmitted(brstatus) + + def removeBuildRequest(self, brstatus): + self.pendingBuilds.remove(brstatus) + + # buildStarted is called by our child BuildStatus instances + def buildStarted(self, s): + """Now the BuildStatus object is ready to go (it knows all of its + Steps, its ETA, etc), so it is safe to notify our watchers.""" + + assert s.builder is self # paranoia + assert s.number == self.nextBuildNumber - 1 + assert s not in self.currentBuilds + self.currentBuilds.append(s) + self.addBuildToCache(s) + + # now that the BuildStatus is prepared to answer queries, we can + # announce the new build to all our watchers + + for w in self.watchers: # TODO: maybe do this later? callLater(0)? + try: + receiver = w.buildStarted(self.getName(), s) + if receiver: + if type(receiver) == type(()): + s.subscribe(receiver[0], receiver[1]) + else: + s.subscribe(receiver) + d = s.waitUntilFinished() + d.addCallback(lambda s: s.unsubscribe(receiver)) + except: + log.msg("Exception caught notifying %r of buildStarted event" % w) + log.err() + + def _buildFinished(self, s): + assert s in self.currentBuilds + s.saveYourself() + self.currentBuilds.remove(s) + + name = self.getName() + results = s.getResults() + for w in self.watchers: + try: + w.buildFinished(name, s, results) + except: + log.msg("Exception caught notifying %r of buildFinished event" % w) + log.err() + + self.prune() # conserve disk + + + # waterfall display (history) + + # I want some kind of build event that holds everything about the build: + # why, what changes went into it, the results of the build, itemized + # test results, etc. But, I do kind of need something to be inserted in + # the event log first, because intermixing step events and the larger + # build event is fraught with peril. Maybe an Event-like-thing that + # doesn't have a file in it but does have links. Hmm, that's exactly + # what it does now. The only difference would be that this event isn't + # pushed to the clients. + + # publish to clients + def sendLastBuildStatus(self, client): + #client.newLastBuildStatus(self.lastBuildStatus) + pass + def sendCurrentActivityBigToEveryone(self): + for s in self.subscribers: + self.sendCurrentActivityBig(s) + def sendCurrentActivityBig(self, client): + state = self.currentBigState + if state == "offline": + client.currentlyOffline() + elif state == "idle": + client.currentlyIdle() + elif state == "building": + client.currentlyBuilding() + else: + log.msg("Hey, self.currentBigState is weird:", state) + + + ## HTML display interface + + def getEventNumbered(self, num): + # deal with dropped events, pruned events + first = self.events[0].number + if first + len(self.events)-1 != self.events[-1].number: + log.msg(self, + "lost an event somewhere: [0] is %d, [%d] is %d" % \ + (self.events[0].number, + len(self.events) - 1, + self.events[-1].number)) + for e in self.events: + log.msg("e[%d]: " % e.number, e) + return None + offset = num - first + log.msg(self, "offset", offset) + try: + return self.events[offset] + except IndexError: + return None + + ## Persistence of Status + def loadYourOldEvents(self): + if hasattr(self, "allEvents"): + # first time, nothing to get from file. Note that this is only if + # the Application gets .run() . If it gets .save()'ed, then the + # .allEvents attribute goes away in the initial __getstate__ and + # we try to load a non-existent file. + return + self.allEvents = self.loadFile("events", []) + if self.allEvents: + self.nextEventNumber = self.allEvents[-1].number + 1 + else: + self.nextEventNumber = 0 + def saveYourOldEvents(self): + self.saveFile("events", self.allEvents) + + ## clients + + def addClient(self, client): + if client not in self.subscribers: + self.subscribers.append(client) + self.sendLastBuildStatus(client) + self.sendCurrentActivityBig(client) + client.newEvent(self.currentSmall) + def removeClient(self, client): + if client in self.subscribers: + self.subscribers.remove(client) + +class SlaveStatus: + implements(interfaces.ISlaveStatus) + + admin = None + host = None + connected = False + graceful_shutdown = False + + def __init__(self, name): + self.name = name + self._lastMessageReceived = 0 + self.runningBuilds = [] + self.graceful_callbacks = [] + + def getName(self): + return self.name + def getAdmin(self): + return self.admin + def getHost(self): + return self.host + def isConnected(self): + return self.connected + def lastMessageReceived(self): + return self._lastMessageReceived + def getRunningBuilds(self): + return self.runningBuilds + + def setAdmin(self, admin): + self.admin = admin + def setHost(self, host): + self.host = host + def setConnected(self, isConnected): + self.connected = isConnected + def setLastMessageReceived(self, when): + self._lastMessageReceived = when + + def buildStarted(self, build): + self.runningBuilds.append(build) + def buildFinished(self, build): + self.runningBuilds.remove(build) + + def getGraceful(self): + """Return the graceful shutdown flag""" + return self.graceful_shutdown + def setGraceful(self, graceful): + """Set the graceful shutdown flag, and notify all the watchers""" + self.graceful_shutdown = graceful + for cb in self.graceful_callbacks: + reactor.callLater(0, cb, graceful) + def addGracefulWatcher(self, watcher): + """Add watcher to the list of watchers to be notified when the + graceful shutdown flag is changed.""" + if not watcher in self.graceful_callbacks: + self.graceful_callbacks.append(watcher) + def removeGracefulWatcher(self, watcher): + """Remove watcher from the list of watchers to be notified when the + graceful shutdown flag is changed.""" + if watcher in self.graceful_callbacks: + self.graceful_callbacks.remove(watcher) + +class Status: + """ + I represent the status of the buildmaster. + """ + implements(interfaces.IStatus) + + def __init__(self, botmaster, basedir): + """ + @type botmaster: L{buildbot.master.BotMaster} + @param botmaster: the Status object uses C{.botmaster} to get at + both the L{buildbot.master.BuildMaster} (for + various buildbot-wide parameters) and the + actual Builders (to get at their L{BuilderStatus} + objects). It is not allowed to change or influence + anything through this reference. + @type basedir: string + @param basedir: this provides a base directory in which saved status + information (changes.pck, saved Build status + pickles) can be stored + """ + self.botmaster = botmaster + self.basedir = basedir + self.watchers = [] + self.activeBuildSets = [] + assert os.path.isdir(basedir) + # compress logs bigger than 4k, a good default on linux + self.logCompressionLimit = 4*1024 + + + # methods called by our clients + + def getProjectName(self): + return self.botmaster.parent.projectName + def getProjectURL(self): + return self.botmaster.parent.projectURL + def getBuildbotURL(self): + return self.botmaster.parent.buildbotURL + + def getURLForThing(self, thing): + prefix = self.getBuildbotURL() + if not prefix: + return None + if interfaces.IStatus.providedBy(thing): + return prefix + if interfaces.ISchedulerStatus.providedBy(thing): + pass + if interfaces.IBuilderStatus.providedBy(thing): + builder = thing + return prefix + "builders/%s" % ( + urllib.quote(builder.getName(), safe=''), + ) + if interfaces.IBuildStatus.providedBy(thing): + build = thing + builder = build.getBuilder() + return prefix + "builders/%s/builds/%d" % ( + urllib.quote(builder.getName(), safe=''), + build.getNumber()) + if interfaces.IBuildStepStatus.providedBy(thing): + step = thing + build = step.getBuild() + builder = build.getBuilder() + return prefix + "builders/%s/builds/%d/steps/%s" % ( + urllib.quote(builder.getName(), safe=''), + build.getNumber(), + urllib.quote(step.getName(), safe='')) + # IBuildSetStatus + # IBuildRequestStatus + # ISlaveStatus + + # IStatusEvent + if interfaces.IStatusEvent.providedBy(thing): + from buildbot.changes import changes + # TODO: this is goofy, create IChange or something + if isinstance(thing, changes.Change): + change = thing + return "%schanges/%d" % (prefix, change.number) + + if interfaces.IStatusLog.providedBy(thing): + log = thing + step = log.getStep() + build = step.getBuild() + builder = build.getBuilder() + + logs = step.getLogs() + for i in range(len(logs)): + if log is logs[i]: + lognum = i + break + else: + return None + return prefix + "builders/%s/builds/%d/steps/%s/logs/%d" % ( + urllib.quote(builder.getName(), safe=''), + build.getNumber(), + urllib.quote(step.getName(), safe=''), + lognum) + + def getChangeSources(self): + return list(self.botmaster.parent.change_svc) + + def getChange(self, number): + return self.botmaster.parent.change_svc.getChangeNumbered(number) + + def getSchedulers(self): + return self.botmaster.parent.allSchedulers() + + def getBuilderNames(self, categories=None): + if categories == None: + return self.botmaster.builderNames[:] # don't let them break it + + l = [] + # respect addition order + for name in self.botmaster.builderNames: + builder = self.botmaster.builders[name] + if builder.builder_status.category in categories: + l.append(name) + return l + + def getBuilder(self, name): + """ + @rtype: L{BuilderStatus} + """ + return self.botmaster.builders[name].builder_status + + def getSlaveNames(self): + return self.botmaster.slaves.keys() + + def getSlave(self, slavename): + return self.botmaster.slaves[slavename].slave_status + + def getBuildSets(self): + return self.activeBuildSets[:] + + def generateFinishedBuilds(self, builders=[], branches=[], + num_builds=None, finished_before=None, + max_search=200): + + def want_builder(bn): + if builders: + return bn in builders + return True + builder_names = [bn + for bn in self.getBuilderNames() + if want_builder(bn)] + + # 'sources' is a list of generators, one for each Builder we're + # using. When the generator is exhausted, it is replaced in this list + # with None. + sources = [] + for bn in builder_names: + b = self.getBuilder(bn) + g = b.generateFinishedBuilds(branches, + finished_before=finished_before, + max_search=max_search) + sources.append(g) + + # next_build the next build from each source + next_build = [None] * len(sources) + + def refill(): + for i,g in enumerate(sources): + if next_build[i]: + # already filled + continue + if not g: + # already exhausted + continue + try: + next_build[i] = g.next() + except StopIteration: + next_build[i] = None + sources[i] = None + + got = 0 + while True: + refill() + # find the latest build among all the candidates + candidates = [(i, b, b.getTimes()[1]) + for i,b in enumerate(next_build) + if b is not None] + candidates.sort(lambda x,y: cmp(x[2], y[2])) + if not candidates: + return + + # and remove it from the list + i, build, finshed_time = candidates[-1] + next_build[i] = None + got += 1 + yield build + if num_builds is not None: + if got >= num_builds: + return + + def subscribe(self, target): + self.watchers.append(target) + for name in self.botmaster.builderNames: + self.announceNewBuilder(target, name, self.getBuilder(name)) + def unsubscribe(self, target): + self.watchers.remove(target) + + + # methods called by upstream objects + + def announceNewBuilder(self, target, name, builder_status): + t = target.builderAdded(name, builder_status) + if t: + builder_status.subscribe(t) + + def builderAdded(self, name, basedir, category=None): + """ + @rtype: L{BuilderStatus} + """ + filename = os.path.join(self.basedir, basedir, "builder") + log.msg("trying to load status pickle from %s" % filename) + builder_status = None + try: + builder_status = load(open(filename, "rb")) + styles.doUpgrade() + except IOError: + log.msg("no saved status pickle, creating a new one") + except: + log.msg("error while loading status pickle, creating a new one") + log.msg("error follows:") + log.err() + if not builder_status: + builder_status = BuilderStatus(name, category) + builder_status.addPointEvent(["builder", "created"]) + log.msg("added builder %s in category %s" % (name, category)) + # an unpickled object might not have category set from before, + # so set it here to make sure + builder_status.category = category + builder_status.basedir = os.path.join(self.basedir, basedir) + builder_status.name = name # it might have been updated + builder_status.status = self + + if not os.path.isdir(builder_status.basedir): + os.makedirs(builder_status.basedir) + builder_status.determineNextBuildNumber() + + builder_status.setBigState("offline") + builder_status.setLogCompressionLimit(self.logCompressionLimit) + + for t in self.watchers: + self.announceNewBuilder(t, name, builder_status) + + return builder_status + + def builderRemoved(self, name): + for t in self.watchers: + t.builderRemoved(name) + + def prune(self): + for b in self.botmaster.builders.values(): + b.builder_status.prune() + + def buildsetSubmitted(self, bss): + self.activeBuildSets.append(bss) + bss.waitUntilFinished().addCallback(self.activeBuildSets.remove) + for t in self.watchers: + t.buildsetSubmitted(bss) |