How to leverage an old dirty trick in your Bazel wrapper for great effect
As powerful as Bazel is, sometimes it’s not featureful enough. When using this build system, it’s common practice to wrap it in a launcher script—and in fact, this is natively supported by Bazelisk, Bazel’s native dispatcher that stands for the bazel binary in the user’s PATH. Bazelisk will first download the version of Bazel requested by the project, and then, if tools/bazel exists, invoke it instead of the downloaded binary. tools/bazel is what’s known as a Bazel wrapper and is the point of today’s article.
Well, not quite. The actual point of today’s article is to demonstrate a simple trick I learned from the GNU Autoconf and Automake days to implement full-blown conditionals in an ad-hoc template system. But because such trick is trivial once you see it, I have to present it in the context of a modern real-world scenario. So what I’m going to do is guide you through the creation of your very own Bazel wrapper to customize Bazel’s .bazelrc configuration file in ways that the native Bazel tool doesn’t support.
Let’s get started.
The context
Template systems are everywhere. Take any static blog generation system and you’ll find some. Take system management tools like Ansible and you’ll find others. Take a cloud orchestration service like Kubernetes and you’ll find Helm. Heck, even Go’s standard library provides a full blown text template system out of the box.
There is clear benefit and appetite for these and, surely enough, it’s tempting to use any pre-existing such system in your own project… but if all you need are a bunch of variable replacements, some of which may be only conditionally applied, you can go a long way by not taking any dependencies. A call to sed or the substring function of your language of choice is all you need.
To put this in context, let’s say you have Bazel’s .bazelrc configuration file, which is not very flexible, and that you need to set some arguments based on dynamic values that depend on the environment. E.g. something like this:
# Developer builds
startup --host_jvm_args=-Xmx4g
build --jobs=200
# CI builds
startup --host_jvm_args=-Xmx16g
build --jobs=1000
If you know a little bit about .bazelrc, however, you may squint at that and say: “That’s silly! Make those flags conditional on a configuration and you’re set!” So you try something like this:
# Developer builds
startup:dev --host_jvm_args=-Xmx4g
build:dev --jobs=200
# CI builds
startup:ci --host_jvm_args=-Xmx16g
build:ci --jobs=1000
# Enable dev as default configuration
common --config=dev
# ... but CI can pass --config=ci on the CLI to override.
And… this does not work. Gotcha! Startup flags cannot be placed behind a configuration so there is no way for you to parameterize the JVM’s max heap value passed in -Xmx. And having to remember to pass --config=ci from CI all the time is fragile, because you might forget and not get the desired configuration in place.
Basic string replacements
Solving the above is not difficult if we could parameterize the configuration. We might want to write something like this instead:
startup --host_jvm_args=-Xmx@MAX_HEAP@
build --jobs=@JOBS@
… and then have @MAX_HEAP@ and @JOBS@ be replaced dynamically depending on some runtime arbitrary logic. We can do that via the Bazel wrapper, and this sort of dynamic configuration is a common thing to do from it. So let’s do this.
Let’s start with the template logic:
declare -A SUBSTS
subst() {
local var="${1}"; shift
local value="${1}"; shift
SUBSTS["${var}"]="${value}"
}
generate() {
local in_file="${1}"; shift
local out_file="${1}"; shift
local args=()
for var in "${!SUBSTS[@]}"; do
local value="${SUBSTS[$var]}"
args+=( -e "s|@${var}@|${value}|g" )
done
sed "${args[@]}" "${in_file}" >"${out_file}"
}
Ugly(?) bash syntax but nothing too complicated:
- The global
SUBSTShashmap tracks variable names and their replacement values. - The
substfunction inserts a new key/value pair intoSUBSTS. (It’s important that the values given tosubstdon’t containsed-special characters like&, backslashes, or the|separator we chose—but we control the generation of those values so we are good.) - The
generatefunction transforms the hashmap into a set ofsedarguments of the form-e s|@VAR@|VALUE|gand then callssedto process the given input file into the given output file.
Then, we can plug everything together into a minimal Bazel wrapper:
#! /bin/bash
set -euo pipefail
WORKSPACE="$(cd $(dirname "${0}")/.. && pwd -P)"
readonly WORKSPACE
# ... insert template logic from above.
run() {
local bazelrc
bazelrc="$(mktemp -p "${TMPDIR:-/tmp}" bazelrc.XXXXXXXX)"
trap "rm -f '${bazelrc}'" EXIT HUP INT QUIT TERM
generate "${WORKSPACE}/.bazelrc.in" "${bazelrc}"
"${REAL_BAZEL}" --noworkspace_rc --bazelrc="${bazelrc}" "${@}"
}
main() {
if [[ "${CI:-false}" == true ]]; then
subst MAX_HEAP 16g
subst JOBS 1000
else
subst MAX_HEAP 4g
subst JOBS 200
fi
run "${@}"
}
main "${@}"
In this new snippet, the run function instantiates the .bazelrc file from the contents of SUBSTS via generate and then calls the actual Bazel binary provided by Bazelisk in REAL_BAZEL. The complexity here may seem overkill, but it’s necessary: while it’s pointless to invoke Bazel in parallel due to its global lock, users will run Bazel in parallel and you must make sure that the wrapper is reentrant. Otherwise, you’ll definitely run into races.
The rest of the script in main does the actual work to compute key/value pairs to substitute in your now-templated .bazelrc and then delegates to Bazel via run.
That’s it. This is a barebones implementation of a text template system using bash—and I had to use bash, not sh, to get the niceties of a hashmap—that serves as a tools/bazel launcher. Go try it.
By the way, the @VAR@ and subst nomenclature are inherited from GNU Autoconf’s AC_SUBST primitive.
Conditionals
“Great!” I hear you say in a sarcastic tone. “You have just applied string replacements! But what about conditionals, huh? You CaNnOt Do ThAt So EaSiLy!!11!one!”
Ah, but you can, and showing you that trick is the whole point of this short article, remember?
The necessary insight is that we can use string replacements to comment out lines in the original file. What if we did this:
startup --host_jvm_args=-Xmx@MAX_HEAP@
build:dev --jobs=400
build:ci --jobs=1000
# Pick default build mode.
@CI_FALSE@build --config=dev
@CI_TRUE@build --config=ci
In here, we are defining different configurations for developer workstations and for CI, like we did earlier, but then we are auto-magically picking the default configuration depending on @CI_TRUE@ and @CI_FALSE@. How? Well: @CI_TRUE@ will expand to the empty string when running on CI and @CI_FALSE@ will expand to #, so the corresponding lines will be enabled and disabled. And the opposite replacement values will appear when not running on CI. Ta-da! Conditionals.
We can make things nicer with a helper function and meta-programming:
subst_if() {
local var="${1}"; shift
if eval "${*}"; then
subst "${var}_TRUE" ""
subst "${var}_FALSE" "#"
else
subst "${var}_TRUE" "#"
subst "${var}_FALSE" ""
fi
}
subst_if CI '[[ "${CI:-false}" == true ]]'
Don’t panic about that eval. Just as with the sed invocation above where we could have issues with special characters appearing in values, we control the arguments to subst_if so the eval is safe.
And note that we can even nest conditionals arbitrarily. There is nothing preventing you from doing:
@CONDITION_1_TRUE@@CONDITION_2_TRUE@build --flag1
@CONDITION_1_TRUE@@CONDITION_2_FALSE@build --flag2
@CONDITION_1_FALSE@@CONDITION_2_TRUE@build --flag3
@CONDITION_1_FALSE@@CONDITION_2_FALSE@build --flag4
Which corresponds to the conceptual equivalent of:
if CONDITION_1
if CONDITION_2
build --flag1
else
build --flag2
endif
else
if CONDITION_2
build --flag3
else
build --flag4
endif
endif
Loops
Let’s do loops? Sorry no, can’t do!
Well akshually… we could do loops. Not by using simple tricks like above, but we could definitely sketch something like this:
while read line; do
case "${line}" in
for*)
# Handle the loop logic here...
;;
*)
echo "${line}"
;;
esac
done <"${input}" >"${output}"
However, this is starting to look a lot like a high-level parser, not scripting where you glue simpler components together. And if you are headed that way, you are better off transitioning to a proper programming language and a well-known template system.
So…
What do you think? Do you hate this already? You can, but note that the whole world runs on this stuff. All of that foundational code behind Linux systems ends up using GNU Automake and GNU Autoconf, and those packages are full of stuff like this in their configure and Makefile.in files.
And you can get very far with just the above constructs if you treat the shell like a real language. The Bazel wrapper that I maintain at work these days grew to almost 1000 lines of code before I pruned a lot of features that had become unnecessary, but it’s still pretty large. We are now transitioning it to a Go-based wrapper for better readability and maintainability… but as we do this, I’m reminded that well-groomed shell scripts give you some flexibility that no other language can match in just a few lines.
So, keep things simple. You can do a lot with just a few primitives.
