Caution

This page documents the latest, unreleased version of Buildbot. For documentation for released versions, see https://docs.buildbot.net/current/.

2.6.10. Writing New BuildSteps

Warning

The API of writing custom build steps has changed significantly in Buildbot-0.9.0. See New-Style Build Steps in Buildbot 0.9.0 for details about what has changed since pre 0.9.0 releases. This section documents new-style steps.

While it is a good idea to keep your build process self-contained in the source code tree, sometimes it is convenient to put more intelligence into your Buildbot configuration. One way to do this is to write a custom BuildStep. Once written, this Step can be used in the master.cfg file.

The best reason for writing a custom BuildStep is to better parse the results of the command being run. For example, a BuildStep that knows about JUnit could look at the logfiles to determine which tests had been run, how many passed and how many failed, and then report more detailed information than a simple rc==0 -based good/bad decision.

Buildbot has acquired a large fleet of build steps, and sports a number of knobs and hooks to make steps easier to write. This section may seem a bit overwhelming, but most custom steps will only need to apply one or two of the techniques outlined here.

For complete documentation of the build step interfaces, see BuildSteps.

2.6.10.1. Writing BuildStep Constructors

Build steps act as their own factories, so their constructors are a bit more complex than necessary. The configuration file instantiates a BuildStep object, but the step configuration must be re-used for multiple builds, so Buildbot needs some way to create more steps.

Consider the use of a BuildStep in master.cfg:

f.addStep(MyStep(someopt="stuff", anotheropt=1))

This creates a single instance of class MyStep. However, Buildbot needs a new object each time the step is executed. An instance of BuildStep remembers how it was constructed, and can create copies of itself. When writing a new step class, then, keep in mind that you cannot do anything “interesting” in the constructor – limit yourself to checking and storing arguments.

It is customary to call the parent class’s constructor with all otherwise-unspecified keyword arguments. Keep a **kwargs argument on the end of your options, and pass that up to the parent class’s constructor.

The whole thing looks like this:

class Frobnify(BuildStep):
    def __init__(self,
            frob_what="frobee",
            frob_how_many=None,
            frob_how=None,
            **kwargs):

        # check
        if frob_how_many is None:
            raise TypeError("Frobnify argument how_many is required")

        # override a parent option
        kwargs['parentOpt'] = 'xyz'

        # call parent
        super().__init__(**kwargs)

        # set Frobnify attributes
        self.frob_what = frob_what
        self.frob_how_many = how_many
        self.frob_how = frob_how

class FastFrobnify(Frobnify):
    def __init__(self,
            speed=5,
            **kwargs):
        super().__init__(**kwargs)
        self.speed = speed

2.6.10.2. Step Execution Process

A step’s execution occurs in its run method. When this method returns (more accurately, when the Deferred it returns fires), the step is complete. The method’s result must be an integer, giving the result of the step. Any other output from the step (logfiles, status strings, URLs, etc.) is the responsibility of the run method.

The ShellCommand class implements this run method, and in most cases steps subclassing ShellCommand simply implement some of the subsidiary methods that its run method calls.

2.6.10.3. Running Commands

To spawn a command in the worker, create a RemoteCommand instance in your step’s run method and run it with runCommand:

cmd = RemoteCommand(args)
d = self.runCommand(cmd)

The CommandMixin class offers a simple interface to several common worker-side commands.

For the much more common task of running a shell command on the worker, use ShellMixin. This class provides a method to handle the myriad constructor arguments related to shell commands, as well as a method to create new RemoteCommand instances. This mixin is the recommended method of implementing custom shell-based steps. For simple steps that don’t involve much logic the :bb:step:`ShellCommand is recommended.

A simple example of a step using the shell mixin is:

class RunCleanup(buildstep.ShellMixin, buildstep.BuildStep):
    def __init__(self, cleanupScript='./cleanup.sh', **kwargs):
        self.cleanupScript = cleanupScript
        kwargs = self.setupShellMixin(kwargs, prohibitArgs=['command'])
        super().__init__(**kwargs)

    @defer.inlineCallbacks
    def run(self):
        cmd = yield self.makeRemoteShellCommand(
                command=[self.cleanupScript])
        yield self.runCommand(cmd)
        if cmd.didFail():
            cmd = yield self.makeRemoteShellCommand(
                    command=[self.cleanupScript, '--force'],
                    logEnviron=False)
            yield self.runCommand(cmd)
        return cmd.results()

@defer.inlineCallbacks
def run(self):
    cmd = RemoteCommand(args)
    log = yield self.addLog('output')
    cmd.useLog(log, closeWhenFinished=True)
    yield self.runCommand(cmd)

2.6.10.4. Updating Status Strings

Each step can summarize its current status in a very short string. For example, a compile step might display the file being compiled. This information can be helpful to users eager to see their build finish.

Similarly, a build has a set of short strings collected from its steps summarizing the overall state of the build. Useful information here might include the number of tests run, but probably not the results of a make clean step.

As a step runs, Buildbot calls its getCurrentSummary method as necessary to get the step’s current status. “As necessary” is determined by calls to buildbot.process.buildstep.BuildStep.updateSummary. Your step should call this method every time the status summary may have changed. Buildbot will take care of rate-limiting summary updates.

When the step is complete, Buildbot calls its getResultSummary method to get a final summary of the step along with a summary for the build.

2.6.10.5. About Logfiles

Each BuildStep has a collection of log files. Each one has a short name, like stdio or warnings. Each log file contains an arbitrary amount of text, usually the contents of some output file generated during a build or test step, or a record of everything that was printed to stdout/stderr during the execution of some command.

Each can contain multiple channels, generally limited to three basic ones: stdout, stderr, and headers. For example, when a shell command runs, it writes a few lines to the headers channel to indicate the exact argv strings being run, which directory the command is being executed in, and the contents of the current environment variables. Then, as the command runs, it adds a lot of stdout and stderr messages. When the command finishes, a final header line is added with the exit code of the process.

Status display plugins can format these different channels in different ways. For example, the web page shows log files as text/html, with header lines in blue text, stdout in black, and stderr in red. A different URL is available which provides a text/plain format, in which stdout and stderr are collapsed together, and header lines are stripped completely. This latter option makes it easy to save the results to a file and run grep or whatever against the output.

2.6.10.6. Writing Log Files

Most commonly, logfiles come from commands run on the worker. Internally, these are configured by supplying the RemoteCommand instance with log files via the useLog method:

@defer.inlineCallbacks
def run(self):
    ...
    log = yield self.addLog('stdio')
    cmd.useLog(log, closeWhenFinished=True, 'stdio')
    yield self.runCommand(cmd)

The name passed to useLog must match that configured in the command. In this case, stdio is the default.

If the log file was already added by another part of the step, it can be retrieved with getLog:

stdioLog = self.getLog('stdio')

Less frequently, some master-side processing produces a log file. If this log file is short and easily stored in memory, this is as simple as a call to addCompleteLog:

@defer.inlineCallbacks
def run(self):
    ...
    summary = u'\n'.join('%s: %s' % (k, count)
                         for (k, count) in self.lint_results.items())
    yield self.addCompleteLog('summary', summary)

Note that the log contents must be a unicode string.

Longer logfiles can be constructed line-by-line using the add methods of the log file:

@defer.inlineCallbacks
def run(self):
    ...
    updates = yield self.addLog('updates')
    while True:
        ...
        yield updates.addStdout(some_update)

Again, note that the log input must be a unicode string.

Finally, addHTMLLog is similar to addCompleteLog, but the resulting log will be tagged as containing HTML. The web UI will display the contents of the log using the browser.

The logfiles= argument to ShellCommand and its subclasses creates new log files and fills them in realtime by asking the worker to watch an actual file on disk. The worker will look for additions in the target file and report them back to the BuildStep. These additions will be added to the log file by calling addStdout.

All log files can be used as the source of a LogObserver just like the normal stdio LogFile. In fact, it’s possible for one LogObserver to observe a logfile created by another.

2.6.10.7. Reading Logfiles

For the most part, Buildbot tries to avoid loading the contents of a log file into memory as a single string. For large log files on a busy master, this behavior can quickly consume a great deal of memory.

Instead, steps should implement a LogObserver to examine log files one chunk or line at a time.

For commands which only produce a small quantity of output, RemoteCommand will collect the command’s stdout into its stdout attribute if given the collectStdout=True constructor argument.

2.6.10.8. Adding LogObservers

Most shell commands emit messages to stdout or stderr as they operate, especially if you ask them nicely with a option –verbose flag of some sort. They may also write text to a log file while they run. Your BuildStep can watch this output as it arrives, to keep track of how much progress the command has made or to process log output for later summarization.

To accomplish this, you will need to attach a LogObserver to the log. This observer is given all text as it is emitted from the command, and has the opportunity to parse that output incrementally.

There are a number of pre-built LogObserver classes that you can choose from (defined in buildbot.process.buildstep, and of course you can subclass them to add further customization. The LogLineObserver class handles the grunt work of buffering and scanning for end-of-line delimiters, allowing your parser to operate on complete stdout/stderr lines.

For example, let’s take a look at the TrialTestCaseCounter, which is used by the Trial step to count test cases as they are run. As Trial executes, it emits lines like the following:

buildbot.test.test_config.ConfigTest.testDebugPassword ... [OK]
buildbot.test.test_config.ConfigTest.testEmpty ... [OK]
buildbot.test.test_config.ConfigTest.testIRC ... [FAIL]
buildbot.test.test_config.ConfigTest.testLocks ... [OK]

When the tests are finished, trial emits a long line of ====== and then some lines which summarize the tests that failed. We want to avoid parsing these trailing lines, because their format is less well-defined than the [OK] lines.

A simple version of the parser for this output looks like this. The full version is in master/buildbot/steps/python_twisted.py.

from buildbot.plugins import util

class TrialTestCaseCounter(util.LogLineObserver):
    _line_re = re.compile(r'^([\w\.]+) \.\.\. \[([^\]]+)\]$')
    numTests = 0
    finished = False

    def outLineReceived(self, line):
        if self.finished:
            return
        if line.startswith("=" * 40):
            self.finished = True
            return

        m = self._line_re.search(line.strip())
        if m:
            testname, result = m.groups()
            self.numTests += 1
            self.step.setProgress('tests', self.numTests)

This parser only pays attention to stdout, since that’s where trial writes the progress lines. It has a mode flag named finished to ignore everything after the ==== marker, and a scary-looking regular expression to match each line while hopefully ignoring other messages that might get displayed as the test runs.

Each time it identifies that a test has been completed, it increments its counter and delivers the new progress value to the step with self.step.setProgress. This helps Buildbot to determine the ETA for the step.

To connect this parser into the Trial build step, Trial.__init__ ends with the following clause:

# this counter will feed Progress along the 'test cases' metric
counter = TrialTestCaseCounter()
self.addLogObserver('stdio', counter)
self.progressMetrics += ('tests',)

This creates a TrialTestCaseCounter and tells the step that the counter wants to watch the stdio log. The observer is automatically given a reference to the step in its step attribute.

2.6.10.9. Using Properties

In custom BuildSteps, you can get and set the build properties with the getProperty and setProperty methods. Each takes a string for the name of the property, and returns or accepts an arbitrary JSON-able (lists, dicts, strings, and numbers) object. For example:

class MakeTarball(buildstep.ShellMixin, buildstep.BuildStep):
    def __init__(self, **kwargs):
        kwargs = self.setupShellMixin(kwargs)
        super().__init__(**kwargs)

    @defer.inlineCallbacks
    def run(self):
        if self.getProperty("os") == "win":
            # windows-only command
            cmd = yield self.makeRemoteShellCommand(commad=[ ... ])
        else:
            # equivalent for other systems
            cmd = yield self.makeRemoteShellCommand(commad=[ ... ])
        yield self.runCommand(cmd)
        return cmd.results()

Remember that properties set in a step may not be available until the next step begins. In particular, any Property or Interpolate instances for the current step are interpolated before the step starts, so they cannot use the value of any properties determined in that step.

2.6.10.10. Using Statistics

Statistics can be generated for each step, and then summarized across all steps in a build. For example, a test step might set its warnings statistic to the number of warnings observed. The build could then sum the warnings on all steps to get a total number of warnings.

Statistics are set and retrieved with the setStatistic and getStatistic methods. The hasStatistic method determines whether a statistic exists.

The Build method getSummaryStatistic can be used to aggregate over all steps in a Build.

2.6.10.11. BuildStep URLs

Each BuildStep has a collection of links. Each has a name and a target URL. The web display displays clickable links for each link, making them a useful way to point to extra information about a step. For example, a step that uploads a build result to an external service might include a link to the uploaded file.

To set one of these links, the BuildStep should call the addURL method with the name of the link and the target URL. Multiple URLs can be set. For example:

@defer.inlineCallbacks
def run(self):
    ... # create and upload report to coverage server
    url = 'http://coverage.example.com/reports/%s' % reportname
    yield self.addURL('coverage', url)

This also works from log observers, which is helpful for instance if the build output points to an external page such as a detailed log file. The following example parses output of poudriere, a tool for building packages on the FreeBSD operating system.

Example output:

[00:00:00] Creating the reference jail... done
...
[00:00:01] Logs: /usr/local/poudriere/data/logs/bulk/103amd64-2018Q4/2018-10-03_05h47m30s
...
... build log without details (those are in the above logs directory) ...

Log observer implementation:

c = BuildmasterConfig = {}
c['titleURL'] = 'https://my-buildbot.example.com/'
# ...
class PoudriereLogLinkObserver(util.LogLineObserver):
    _regex = re.compile(
        r'Logs: /usr/local/poudriere/data/logs/bulk/([-_/0-9A-Za-z]+)$')

    def __init__(self):
        super().__init__()
        self._finished = False

    def outLineReceived(self, line):
        # Short-circuit if URL already found
        if self._finished:
            return

        m = self._regex.search(line.rstrip())
        if m:
            self._finished = True
            # Let's assume local directory /usr/local/poudriere/data/logs/bulk
            # is available as https://my-buildbot.example.com/poudriere/logs
            poudriere_ui_url = c['titleURL'] + 'poudriere/logs/' + m.group(1)
            # Add URLs for build overview page and for per-package log files
            self.step.addURL('Poudriere build web interface', poudriere_ui_url)
            self.step.addURL('Poudriere logs', poudriere_ui_url + '/logs/')

2.6.10.12. Discovering files

When implementing a BuildStep it may be necessary to know about files that are created during the build. There are a few worker commands that can be used to find files on the worker and test for the existence (and type) of files and directories.

The worker provides the following file-discovery related commands:

  • stat calls os.stat for a file in the worker’s build directory. This can be used to check if a known file exists and whether it is a regular file, directory or symbolic link.

  • listdir calls os.listdir for a directory on the worker. It can be used to obtain a list of files that are present in a directory on the worker.

  • glob calls glob.glob on the worker, with a given shell-style pattern containing wildcards.

For example, we could use stat to check if a given path exists and contains *.pyc files. If the path does not exist (or anything fails) we mark the step as failed; if the path exists but is not a directory, we mark the step as having “warnings”.

from buildbot.plugins import steps, util
from buildbot.process import remotecommand
from buildbot.interfaces import WorkerSetupError
import stat

class MyBuildStep(steps.BuildStep):

    def __init__(self, dirname, **kwargs):
        super().__init__(**kwargs)
        self.dirname = dirname

    @defer.inlineCallbacks
    def run(self):
        # make sure the worker knows about stat
        workerver = (self.workerVersion('stat'),
                    self.workerVersion('glob'))
        if not all(workerver):
            raise WorkerSetupError('need stat and glob')

        cmd = remotecommand.RemoteCommand('stat', {'file': self.dirname})

        yield self.runCommand(cmd)

        if cmd.didFail():
            self.description = ["File not found."]
            return util.FAILURE

        s = cmd.updates["stat"][-1]
        if not stat.S_ISDIR(s[stat.ST_MODE]):
            self.description = ["'tis not a directory"]
            return util.WARNINGS

        cmd = remotecommand.RemoteCommand('glob', {'path': self.dirname + '/*.pyc'})

        yield self.runCommand(cmd)

        if cmd.didFail():
            self.description = ["Glob failed."]
            return util.FAILURE

        files = cmd.updates["files"][-1]
        if len(files):
            self.description = ["Found pycs"] + files
        else:
            self.description = ["No pycs found"]
        return util.SUCCESS

For more information on the available commands, see Master-Worker API.

Todo

Step Progress BuildStepFailed