Device management: tools on your developers PATH

Alex EagleAlex Eagle
4 min read

“Device Management”, or MDM, is that thing which forces your work computer to have security software installed. It has the ability to push tools to developer machines too - however it’s owned by the security team at your company. While it would be convenient, I’ve found it’s difficult for the Developer Platform team to use that to distribute the "canonical developer environment”.

Some folks use a devcontainer or VDI (Virtual Desktop Infrastructure) to describe the tooling you need installed, but that’s heavy and slow. If we’re using Bazel, it already has features to give a hermetic environment, right?

A year ago, I wrote an article describing how to get tools on your developers local machine using Bazel, and today I have an update.

In the technique from my original post (https://blog.aspect.build/run-tools-installed-by-bazel), users have to change their behavior, typing ./tools/my-tool rather than just my-tool. Retraining developers to have a different command under their fingers is hard, especially for things they run all the time.

Shortly after I wrote that post, Fabian Meumertzheim created https://github.com/buildbuddy-io/bazel_env.bzl. This is an alternative technique I’ll write about today. It puts the tools on the $PATH instead, fixing the ergonomic issue from the first technique — however there are always trade-offs! This one requires engineers manually install the direnv tool before they get setup.

How direnv works

direnv is a command-line tool that automatically sets and unsets environment variables when you cd into or out of a directory. It’s especially useful for managing project-specific environment variables, such as secrets, configuration settings, or language versions (like Python or Node.js versions).

  • You place a .envrc file in your project directory.

  • This .envrc file contains shell commands to export environment variables or run setup scripts.

  • When you enter the directory, direnv loads the .envrc file and applies the environment changes.

  • When you leave the directory, it automatically reverts those changes.

Here’s how it looks when I enter Aspect’s monorepo (named “silo”):

alexeagle@aspect-build ~ % cd Projects/silo
direnv: loading ~/Projects/silo/.envrc
direnv: export ~PATH

Installing direnv isn’t just a matter of getting the program on your machine. It also needs to hook into your shell (it supports bash, zsh and many others). And finally, you must explicitly allow each .envrc to be trusted.

To follow this pattern, you’ll need to instruct your developers to install this first tool manually. Fortunately they’ll get a reminder of the instructions in the next step.

bazel_env creates a .envrc

After installing bazel_env.bzl you’ll have a runnable Bazel target, typically bazel run //:_bazel_env or bazel run //tools:bazel_env.

The bazel_env target is defined with a dictionary that maps a tool name to put on the PATH, to some other target that provides it. What kinds of targets can those be? Take a look at the example: https://github.com/buildbuddy-io/bazel_env.bzl/blob/main/examples/BUILD.bazel

  1. Binary targets for programs you author yourself in the monorepo

  2. Tools provided by a toolchain, like go, node, pnpm, cargo, etc

  3. With https://github.com/theoremlp/rules_multitool you can run multitool to update a tools.lock.json file, and all of these tools are installed.

  4. CLI utilities distributed by a package manager, like console scripts from PyPI, bin entries from the package.json of NPM packages, Go utilities (see scaffold example here), and so on.

% bazel run //tools:bazel_env
INFO: Analyzed target //tools:bazel_env (580 packages loaded, 72963 targets configured).
INFO: Found 1 target...
Target //tools:bazel_env up-to-date:
  bazel-bin/tools/bazel_env_all_tools
INFO: Elapsed time: 9.399s, Critical Path: 0.45s
INFO: Running command line: bazel-bin/tools/bazel_env.sh

====== bazel_env ======

✅ direnv is installed
✅ direnv added bazel-out/bazel_env-opt/bin/tools/bazel_env/bin to PATH

Tools available in PATH:
  * aws:             @aws
  * pnpm:            @pnpm
  * gofumpt:         @@rules_multitool~~multitool~multitool//tools/gofumpt:gofumpt
  * jsonnetfmt:      @@rules_multitool~~multitool~multitool//tools/jsonnetfmt:jsonnetfmt
  * shfmt:           @@rules_multitool~~multitool~multitool//tools/shfmt:shfmt
  * terraform:       //tools:terraform
  * yamlfmt:         @@rules_multitool~~multitool~multitool//tools/yamlfmt:yamlfmt
  * ruff:            @@rules_multitool~~multitool~multitool//tools/ruff:ruff
  * shellcheck:      @@rules_multitool~~multitool~multitool//tools/shellcheck:shellcheck
  * buf:             @@rules_multitool~~multitool~multitool//tools/buf:buf
  * buildozer:       @@rules_multitool~~multitool~multitool//tools/buildozer:buildozer
  * docker-compose:  @@rules_multitool~~multitool~multitool//tools/docker-compose:docker-compose
  * diesel-cli:      @@rules_multitool~~multitool~multitool//tools/diesel-cli:diesel-cli
  * etcdctl:         @@rules_multitool~~multitool~multitool//tools/etcdctl:etcdctl
  * grpcurl:         @@rules_multitool~~multitool~multitool//tools/grpcurl:grpcurl
  * ibazel:          @@rules_multitool~~multitool~multitool//tools/ibazel:ibazel
  * multitool:       @@rules_multitool~~multitool~multitool//tools/multitool:multitool
  * otel-cli:        @@rules_multitool~~multitool~multitool//tools/otel-cli:otel-cli
  * otelcol-contrib: @@rules_multitool~~multitool~multitool//tools/otelcol-contrib:otelcol-contrib
  * promtool:        @@rules_multitool~~multitool~multitool//tools/promtool:promtool
  * pyrra:           @@rules_multitool~~multitool~multitool//tools/pyrra:pyrra
  * tflint:          @@rules_multitool~~multitool~multitool//tools/tflint:tflint
  * tfsec:           @@rules_multitool~~multitool~multitool//tools/tfsec:tfsec
  * buildifier:      @buildifier_prebuilt//:buildifier
  * scaffold:        @com_github_hay_kot_scaffold//:scaffold
  * node:            $(NODE_PATH)
  * cargo:           $(CARGO)
  * rustfmt:         $(RUSTFMT)

Toolchains available at stable relative paths:
  * nodejs: bazel-out/bazel_env-opt/bin/tools/bazel_env/toolchains/nodejs
  * rust:   bazel-out/bazel_env-opt/bin/tools/bazel_env/toolchains/rust

direnv: loading ~/Projects/silo/.envrc
direnv: export ~PATH

Guardrails

There are a few things that can go wrong, which are worth pointing out.

You’ll note that the tools are actually installed under a named output folder, bazel-out/bazel_env-opt - what if the user runs a bazel clean? This case is well handled, since direnv is able to report errors in the .envrc file with a custom message:

alexeagle@aspect-build silo % bazel clean
INFO: Starting clean (this may take a while). Consider using --async if the clean takes more than several minutes.
direnv: loading ~/Projects/silo/.envrc
direnv: ERROR[bazel_env.bzl]: Run 'bazel run //tools:bazel_env' to regenerate bazel-out/bazel_env-opt/bin/tools/bazel_env/bin
direnv: export ~PATH

Any time the tools change, everyone has to run it again.

Not lazy (yet)

When you run bazel_env it needs to fetch all the tools, even those you never plan to run. See https://github.com/buildbuddy-io/bazel_env.bzl/issues/14

As a workaround, you can have multiple bazel_env targets, but users have to choose the right one for the work they intend to do.

0
Subscribe to my newsletter

Read articles from Alex Eagle directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Alex Eagle
Alex Eagle

Fixing Bazel!