For the last couple of years I have been building command line applications in Go using Bazel. As part of the development workflow it’s important to run the application as it is expected to be run by other people - the customers. That means installing the CLI on to the path. Bazel works by building everything in a sandbox directory. This is great for keeping the source tree clean but makes installing localy slightly harder. One option would be to install the application after it has been built and published but that does not help out with experimentation - trying features and approaches that could end up as dead ends.

In this blog we explore using a custom command line arguments and some Starlark rules to install the cli as part of the build.

Official bazel/starlark documentation: https://docs.bazel.build/versions/main/skylark/config.html. You may notice the name skylark being used in directory names and some older documentation. Starlark is the official name. In the link above the url uses skylark but the documentation is all about starlark.

Problem

Lets say we want to be able to install a binary created by bazel into a local directory. Bazel uses a sandbox for all builds and provides a symlinks inside the bazel workspace for the sandbox. One option would be to just write a small shell script to copy the built application from the sandbox onto our local path.

If we chose this option then this would be a very short article. We would also run into some potential problems.

  • We would have to run the script manually and keep track of when the binary is built

  • The sandbox directory can change over time breaking the script

It would be convenient if we could define a rule to copy the output of a build to a convenient location and even better if we could provide that path on the command line. This avoids coupling into the sandbox and only copies the application if the app changes.

First lets work out what it takes to accept command line arguments in our build

Command line arguments

Native settings - settings that are defined within the bazel application by the bazel authors

Users can query the values (in build scripts) and change those values by passing the values as arguments.

User-defined settings are defined within your own workspace. The settings are defined in BUILD.bazel files and the supporting rules are defined in Bazel (.bzl) files. These setting’s rules are marked as flags and the flag values passed to the build on the bazel command line to customize the build.

Defining a user-defined build setting that can be passed through the bazel command line fits the bill. Using this approach we can use bazel run to install and specify where we would like the application to be installed.

TL;DR the installation command
bazel run //app/hello:install --//:local-install-path=/foo

Let’s first break down what’s happening and then walk through how this works with rule definitions.

bazel (1)
        run (2)
        //app/hello:install (3)
        --//:local-install-path=/foo (4)
1 The usual bazel command line application
2 run maps this to a run rule (the default) we could change this by defining a build or test rul but run seems to fit the semantics - run install
3 The application //app/hello to be installed with the :install target
4 Set the installation path to /foo on the workstation. -- specifies that this is a command line flag // specifies that we are defining the value with an absolute path within the workspace adn :local-install-path references the required variable in the top level build file in the workspace. This syntax is not as clean as the usual *NIX style of --local-install-path but it is more flexible. We can define command line arguments at any point in the workspace tree and reference them uniquely even if they have the same name. It also avoids conflicts with native command line arguments.

The application build file

For this example we are building an application in Go. The app itself is trivial - prints 'Hello' to the console, but it is sufficient for this example. Bazel uses the Go toolchain to compile and link the hello application - all in the sandbox.

The directory structure is significant. Bazel uses the directory structure to infer the build file to use based on location.

bazel build //app/hello expects a BUILD or BUILD.bazel file in the app/hello directory that contains the WORKSPACE.

app/hello/BUILD.bazel
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") (1)
load("//:rules/defs.bzl", "local_deploy") (2)

go_library( (3)
    name = "hello_lib",
    srcs = ["hello.go"],
    importpath = "github.com/grahambrooks/building-with-bazel/hello/app/hello",
    visibility = ["//visibility:private"],
)

go_binary( (4)
    name = "hello", (5)
    embed = [":hello_lib"],
    visibility = ["//visibility:public"],
)

local_deploy( (6)
    name = "install",  (7)
    srcs = [":hello"],  (8)
)
1 Imports the rules for building go applications - library and binary
2 Imports our local build defintion for local deploy
3 go libarary rule to build the hello.go file
4 Matching binary rule that packages the library as a binary hello
5 Name for the binary. Must match the local deploy src argument value defined in local_deploy() call..
6 Call the local_deploy rule defined in //:rules/defs.bzl
7 the build target name to install the application bazel run //app/hello:install --//:local-install-path=/foo in our example. The choice of name is up to you as long as the characters you choose are acceptable to bazel for rule naming the hello app
8 The application(s) to install. :hello matches the go binary rule name in this build file.

We are not going to cover building the go app but instead focus on installing locally and being able to change the installation directory.

Defining the local_deploy rule.

The local_deploy rule is defined in a .bzl file and imported into the BUILD file using load(). Rules are typically defined in two parts the exported rule declaration and the implementation as a bazel function.

The rule is defined using the rule() method.

rules/defs.bzl
local_deploy = rule( (1)
    executable = True, (2)
    implementation = _local_deploy_impl, (3)
    attrs = {
        "srcs": attr.label_list(allow_files = True), (4)
        "_install_path": attr.label(default = ":local-install-path") (5)
    },
)
1 sets local_deploy to be the result of defining a rule. The result of calling rule is a function type that encapsulates the fields defined in the call.
2 Defines this as an executable rule that can be called with bazel run
3 Required implementation for the rule <see below>
4 Defines a srcs attribute for the rule that in our case accepts the generated binary files.
5 The installation path attribute as a label referencing a yet to be defined argument defined at the root level of the workspace.

Implementing local_deploy

rules/defs.bzl
def _local_deploy_impl(ctx): (1)
    target = ctx.attr._install_path[InstallDirectoryProvider].path  (2)

    shell_commands = ""
    for s in ctx.files.srcs:
        shell_commands += "echo Installing %s to %s\n" % (s.short_path, target)
        shell_commands += "cp %s %s\n" % (s.short_path, target) (3)

    ctx.actions.write(
        output = ctx.outputs.executable,  (4)
        is_executable = True,  (5)
        content = shell_commands,  (6)
    )
    runfiles = ctx.runfiles(files = ctx.files.srcs)  (7)
    return DefaultInfo(  (8)
        executable = ctx.outputs.executable,  (9)
        runfiles = runfiles,  (10)
    )
1 All rule implementation functions take a single argument ctx
2 Reads the target installation directory from the context using the defined provider [_defining_the_provider]
3 Build a string representing the contents of a shell script that bazel will pass to the shell for execution.
4 Write the output to be executed.
5 Mark it as executable.
6 Set the content of the action to be our generated shell script text.
7 Make sure that the source binary files are available to the shell when the action in run.
8 Return a provider to bazel which is called to complete the action
9 include the executable to be run
10 and the runfiles to execute agains.

We now have everything except the target path. For this we are going to define a provider.

Defining the provider

Out of the box bazel supports boolean and string build settings. Other types can be defined with custom user defined functions (out of scope for this article)..

There are several functions that return providers. The DefaultInfo provider in local_deploy is a provider returned from a rule. For our purposes we define a provider type using the provider method. See Providers.

InstallDirectoryProvider = provider(fields = ['path']) (1)
1 Create a new provider constructor that builds structures (objects) with a single field path e.g. InstallDirectoryProvider(path="/usr/bin") returns an object with a path field set to the string value "/usr/bin".

We need to define a rule that represents the command line flag. The rule needs an implementation and a build_setting parameter. build_setting is a config type. For a command line flag that accepts a path we use a config.string and set the flag parameter to true so the value can be set via the command line.

Rule Declaration
install_path = rule(
    implementation = _install_path_impl, (1)
    build_setting = config.string(flag = True) (2)
)
1 The implementation of the rule
2 Mark this rule as a build setting rule that can be defined/overridden on the command line.
Rule Implementation
def _install_path_impl(ctx):
    return InstallDirectoryProvider(path = ctx.build_setting_value) (1)
1 For our use-case the rule takes the given value and wraps it by calling the provider to return an object. The build_setting_value is automatically defined because we defined the build_setting parameter when we defined the install_path rule

Defining the command line flag in the top level BUILD file with public visibility means that we can use the flag from multiple build rules for components in the repository.

BUILD.bazel
load("//:rules/defs.bzl", "install_path")

install_path(
    name = "local-install-path", (1)
    build_setting_default = "/usr/local/bin", (2)
    visibility = ["//visibility:public"], (3)
)
1 The name value defines the name of the command line flag
2 Set the default argument value to /usr/local/bin
3 Default visibility (//visibility:private) would mean the value would only be accessible within the top level build. By setting public visibility rules can access the value anywhere in the build.

Running the app and install

bazel run //app/hello (1)
bazel run //app/hello:install (2)
bazel run //app/hello:install --//:local-install-path=foo (3)
bazel run //app/hello:install --//:local-install-path=/foo (4)
1 Run the application - in the sandbox
2 Run the install. Installing the built application into the default location.
3 Install the application into a local path in the sandbox - fail
4 Install the application into /foo

Cleaning up the command line

--//:local-install-path is a pretty inconvenient and not what most applications use. Bazel allows us to define aliases for command line arguments, and it is convenient to define these in a .bazelrc file like so:

.bazelrc
run --flag_alias=local_install_path=//:local-install-path  (1)
1 Defines an alias when running bazel run so we can use local_deploy_path. Note the underscores '_' in the alias.
bazel run //app/hello:install --local_install_path=/foo

Much more idiomatic for command line use. Aliasing also means that command line arguments can be defined anywhere in the workspace tree and then aliased for convenience.

We don’t have to stick to a single alias.

.bazelrc
run --flag_alias=install=//:local-install-path
bazel run //app/hello:install --install=/usr/local/bin

Found this article interesting? Found a bug or want more info? Drop me an email or comment below.