Using Macros to Create “Custom Verbs”

Day-to-day interaction with Bazel happens primarily through a few commands: build, test, and run. At times, though, these can feel limited: you may want to push packages to a repository, publish documentation for end-users, or deploy an application with Kubernetes. But Bazel doesn’t have a publish or deploy command – where do these actions fit in?

The versatility of bazel run

Bazel’s focus on hermeticity, reproducibility, and incrementality means the build and test commands aren’t helpful for the above tasks. These actions may run in a sandbox, with limited network access, and aren’t guaranteed to be re-run with every bazel build.

Instead, we rely on bazel run: the workhorse for tasks we want to have side effects. Bazel users are accustomed to rules that create executables, and rule authors can follow a common set of patterns to extend this to “custom verbs”.

In the wild: rules_k8s

For an example, consider rules_k8s, the Kubernetes rules for Bazel. Suppose we have the following target:

# BUILD file in //application/k8s
k8s_object(
    name = "staging",
    kind = "deployment",
    cluster = "testing",
    template = "deployment.yaml",
)

The k8s_object rule builds a standard Kubernetes YAML file when bazel build is used on the staging target. However, the additional targets are also created by the k8s_object macro with names like staging.apply and :staging.delete. These build scripts to perform those actions, and when executed with bazel run staging.apply, these behave like our own bazel k8s-apply or bazel k8s-delete commands.

Another example: ts_api_guardian_test

This pattern can also be seen in the Angular project. The ts_api_guardian_test macro produces two targets. The first is a standard nodejs_test target which compares some generated output against a “golden” file (that is, a file containing the expected output). This can be built and run with a normal bazel test invocation. In angular-cli, we can run one such target with bazel test //etc/api:angular_devkit_core_api.

Over time, this golden file may need to be updated for legitimate reasons. Updating this manually is tedious and error-prone, so this macro also provides a nodejs_binary target that updates the golden file, instead of comparing against it. Effectively, the same test script can be written to run in “verify” or “accept” mode, based on how it’s invoked. This follows the same pattern we’ve learned already! There is no native bazel test-accept command, but the same effect can be achieved with bazel run //etc/api:angular_devkit_core_api.accept.

This pattern can be quite powerful, and turns out to be quite common once you learn to recognize it.

Adapting your own rules

Macros are the heart of this pattern. Macros are used like rules, but they can create several targets. Typically, they will create a target with the specified name which performs the primary build action: perhaps it builds a normal binary, a Docker image, or an archive of source code. In this pattern, additional targets are created to produce scripts performing side effects based on the output of the primary target, like publishing the resulting binary or updating the expected test output.

To illustrate this, we’ll wrap an imaginary rule that generates a website with Sphinx with a macro to create an additional target that allows the user to publish it when ready. Consider the following existing rule for generating a website with Sphinx:

_sphinx_site = rule(
     implementation = _sphinx_impl,
     attrs = {"srcs": attr.label_list(allow_files = [".rst"])},
)

Next, consider a rule like the following, which builds a script that, when run, publishes the generated pages:

_sphinx_publisher = rule(
    implementation = _publish_impl,
    attrs = {
        "site": attr.label(),
        "_publisher": attr.label(
            default = "//internal/sphinx:publisher",
            executable = True,
        ),
    },
    executable = True,
)

Finally, we define the following macro to create targets for both of the above rules together:

def sphinx_site(name, srcs = [], **kwargs):
    # This creates the primary target, producing the Sphinx-generated HTML.
    _sphinx_site(name = name, srcs = srcs, **kwargs)
    # This creates the secondary target, which produces a script for publishing
    # the site generated above.
    _sphinx_publisher(name = "%s.publish" % name, site = name, **kwargs)

In our BUILD files, we use the macro as though it just creates the primary target:

sphinx_site(
    name = "docs",
    srcs = ["index.html", "providers.html"],
)

In this example, a “docs” target is created, just as though the macro were a standard, single Bazel rule. When built, the rule generates some configuration and runs Sphinx to produce an HTML site, ready for manual inspection. However, an additional “docs.publish” target is also created, which builds a script for publishing the site. Once we check the output of the primary target, we can use bazel run :docs.publish to publish it for public consumption, just like an imaginary bazel publish command.

It’s not immediately obvious what the implementation of the _sphinx_publisher rule might look like. Often, actions like this write a launcher shell script. This method typically involves using ctx.actions.expand_template to write a very simple shell script, in this case invoking the publisher binary with a path to the output of the primary target. This way, the publisher implementation can remain generic, the _sphinx_site rule can just produce HTML, and this small script is all that’s necessary to combine the two together.

In rules_k8s, this is indeed what .apply does: expand_template writes a very simple Bash script, based on apply.sh.tpl, which runs kubectl with the output of the primary target. This script can then be build and run with bazel run :staging.apply, effectively providing a k8s-apply command for k8s_object targets.