ARBOR 002 - Four Strategies for Wrapping Ancient External Dependencies in Bazel, From Best to Worst
The ARBOR series (short for “Approaches to Refactoring Big Old Repositories”) covers the techniques I’ve found and implemented when working on large project repositories, typically during a migration into Bazel. I do not claim these techniques are Bazel-idiomatic, but rather “as idiomatic as possible while working with the unique constraints of individual legacy projects”. If your project has a similar set of constraints, maybe they’ll be useful!
In this article, I’ll walk you through four different strategies for integrating external dependencies into a Bazel build, ranked from most maintainable to least. If you’ve been doing Bazel for a while, the fourth may surprise you make you pull your hair out in frustration.
For context, these are the patterns we’re using during the SONiC migration to Bazel, so you might think of this article as a successor to ARBOR 001.
These patterns are especially valuable if you’re migrating a project that:
- has a lot of external dependencies,
- those dependencies are on the old and venerable side,
- needs to patch some of those dependencies,
- is working on a language without great package managers (i.e. C or C++).
But first, an introduction. I’m Borja, I’ve been working with Bazel for almost a decade in large companies like Twitter and Apple, and now I help organizations solve their Bazel woes. If you have Bazel problems, drop me a line!
Anyway, here’s the rundown:
The Four Strategies
Ah, third party deps, the joy of every Bazel migration. Who doesn’t love having to touch that strange wrapper around that weird JSON parsing library that John wrote five years ago, and nobody has dared touch since? Oh, it needs this specific libc, which only shipped in legacy systems? Huh, even better! Can you tell I love this already?
Unfortunately, there’s no way around it: To successfully complete a Bazel migration, we have to understand our dependency graph. More likely than not, we’ll have to understand how our dependencies are built. I’ll share some tips for that in a following article, but for now I’ll just assure you I have the scars to prove that I’ve been in the mines of autotools, ./configure, and debhelper.
Today, I want to focus on the next step: Once you know your dependencies, how do you add them to your Bazel build?
For languages without package managers, you only really have a handful of options. Here they are, presented in a framework, roughly from most maintainable to least maintainable1:
- Use the Bazel Central Registry (BCR) - The dependency already has a Bazel wrapper
- Use system packages - The dependency is available as a prebuilt package
- Port to Bazel - Write Bazel build files for the dependency
- Out-of-band building - Build the dependency outside Bazel and consume the artifacts
Let’s dive into each one.
Strategy 1: Use the Bazel Central Registry (The Easy Path)
Sometimes, thanks to the maintainers of the many modules of the BCR, we don’t have to do anything. Your dependency is already in the Bazel Central Registry, which means someone else has already done the hard work of making it build with Bazel.
The implementation is trivial. Add a single line to your MODULE.bazel:
bazel_dep(name = "openssl", version = "3.5.5.bcr.4")
That’s it. You’re done. Bazel will download the source, apply any patches the maintainer has already figured out, and build it reproducibly across all platforms.
Except…
What If You Need Patches
Of course, nothing is ever that simple in legacy migrations. Maybe you need to add support for a proprietary platform. Maybe you’ve got features that upstream doesn’t want. Maybe you just need some temporary fixes while waiting for a release.
This is where a local Bazel registry comes in handy (see ARBOR 001 for the full pattern). The idea is simple:
- Create a new version of the dependency with a unique identifier (e.g.,
3.5.5.patched) - Copy the BCR entry into your local registry
- Add your patches to a
patches/directory - Update the
source.jsonto reference them
Your directory structure ends up looking like this:
tools/bazel/registry/modules/openssl/3.5.5.patched/
MODULE.bazel
patches
0001_add_custom_cipher.patch
0002_fix_arm64_linkage.patch
series
source.json
And your source.json references the patches:
{
"type": "archive",
"url": "https://github.com/openssl/openssl/releases/download/openssl-3.5.5/openssl-3.5.5.tar.gz",
"integrity": "sha256-...",
"strip_prefix": "openssl-3.5.5",
"patches": {
"0001_add_custom_cipher.patch": "sha256-...",
"0002_fix_arm64_linkage.patch": "sha256-..."
},
"patch_strip": 1
}
Anyone depending on openssl in your organization should then use the patched version:
bazel_dep(name = "openssl", version = "3.5.5.patched")
When to use this: Whenever possible. If the dependency is in the BCR, this is your best option by far. Bazel is a build system that scales wonderfully, and the BCR is a way we can all scale together.
Strategy 2: Use System Packages (The Pragmatic Shortcut)
Sometimes a dependency is available as a prebuilt package (Debian .deb, RPM, Alpine .apk, etc.) and you don’t need to patch it. Maybe it’s a stable library that rarely changes. Maybe it’s something that’s painful to build from source.
In these cases, you can use rules_distroless (or similar tools) to import the package directly.
This approach is clean and low-maintenance. The package maintainers handle the build complexity, you just consume the artifacts.
However, it’s not all roses! In fact, this approach comes with some significant downsides. Because you’re not building the dependencies from source, you lose some optionality:
- You’re tied to specific package versions available in the repository snapshot you’re pulling from.
- Cross-compilation becomes trickier, because you’re only downloading one version of the dependency.
- You can’t patch the source at all.
When to use this: When the dependency is (1) stable, (2) doesn’t need patches, and (3) you don’t need it at build time.
A great use case for this is when you’re using Bazel to assemble final deployment images for containerization. Because you know that they’re going to be deployed to arm64 Debian for instance, you can pick exactly the right package. This is especially common for base system libraries.
Conversely, I strongly recommend you don’t do this for binaries that you need at build time. You don’t know what kind of system your users are going to have. If you pull, say, awk from a Debian repository and use that in a genrule, only people building on Debian will be able to build your project.
Strategy 3: Port to Bazel (The One We Never Want To Do, But Is Often The Right Choice)
This is where things get interesting. Your dependency isn’t in the BCR, and you do need to patch it. Time to write some Bazel build files.
For this section, we’re going to assume you want to patch a dependency called mylib.
We’re going to assume it’s a standard C/C++ library that’s been around for 20 years.
I’m sure you can think of a real-world analogous library.
I break this into two stages:
Stage 1: Standalone Bazel Build
First, get the dependency building with Bazel in isolation.
- Download the source code
cdinto that directory (forget about your main project for now)- Apply your patches
- Write
MODULE.bazelandBUILD.bazelfiles.
For most dependencies, a single BUILD.bazel at the root is enough, and will make your life easier.
This post is long enough already, but in a future ARBOR post, I’ll detail some ideas and tips to make this porting as easy as possible.
When you’re done, the file will typically have targets like:
cc_library(
name = "mylib",
srcs = glob(["src/**/*.c"]),
hdrs = glob(["include/**/*.h"]),
includes = ["include"],
visibility = ["//visibility:public"],
)
cc_binary(
name = "mytool",
srcs = ["tools/main.c"],
deps = [":mylib"],
)
You’re done with this step when you can run bazel build ..., and the dependency produces every artifact you need.
Stage 2: Integration
Once you’ve got it building standalone, it’s time to integrate it into your project.
Every integration needs changes to two files.
1. A new .BUILD file containing the Bazel targets you just worked on:
# mylib.BUILD
# (Name doesn't have to end in .BUILD)
# This should be an exact copy of the file you've been working on for Stage 1.
cc_library(
name = "mylib",
srcs = glob(["src/**/*.c"]),
hdrs = glob(["include/**/*.h"]),
includes = ["include"],
visibility = ["//visibility:public"],
)
cc_binary(
name = "mytool",
srcs = ["tools/main.c"],
deps = [":mylib"],
)
This file will be overlaid on top of the raw sources of the dependency, essentially migrating it to Bazel without changing its source code.
2. In your MODULE.bazel, you need to add an http_archive that specifies how to fetch the source code, which patches to apply, and how to overlay a Bazel build on top of it:
module(
name = "mylib",
version = "2.3.4.patched", # Tip: Make this version match with the version you're patching.
)
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
name = "mylib_source",
# First, fetch the source code and extract it in the sandbox ...
urls = ["https://example.com/mylib-2.3.4.tar.gz"],
sha256 = "...",
strip_prefix = "mylib-2.3.4",
# ... apply any patches you need...
patches = [
"//:patches/0001_fix_build.patch",
"//:patches/0002_add_taco_sauce.patch",
],
patch_args = ["-p1"],
# ... and overlay the Bazel build on top of it
build_file = "//:mylib.BUILD",
)
You can now use @mylib_source as you would any other:
# //:BUILD.bazel, inside of your project
cc_library(
name = "user_of_mylib",
deps = ["@mylib_source//:mylib"],
...
)
What If I Want To Share This Dependency Across Repositories?
If your project is a polyrepo, and you want this dependency to be available across projects, the approach is a bit different. We’re going to turn it into a standalone Bazel module, and incorporate it into our local registry.
- Create a new module in your registry, as described in ARBOR 001:
➜ tree tools/bazel/registry/modules/mylib
tools/bazel/registry/modules/mylib
├── 2.3.4
└── metadata.json
- Then, add the files we just created directly as an overlay:
➜ tree tools/bazel/registry/modules/mylib
tools/bazel/registry/modules/mylib
├── 2.3.4
│ ├── MODULE.bazel -> overlay/MODULE.bazel
│ ├── overlay # These files will be applied directly to the sources
│ │ ├── BUILD.bazel
│ │ └── MODULE.bazel
└── metadata.json
- Lastly, add
sources.json, pointing it to the sourcearchive, your patches, and the overlay:
➜ tree tools/bazel/registry/modules/mylib
tools/bazel/registry/modules/mylib
├── 2.3.4
│ ├── MODULE.bazel -> overlay/MODULE.bazel
│ ├── overlay
│ │ ├── BUILD.bazel
│ │ └── MODULE.bazel
│ ├── patches
│ │ ├── 0001_add_arm_support.patch
│ │ └── 0002_add_leg_support.patch
│ └── source.json
└── metadata.json
$ cat source.json
{
"url": "https://example.com/mylib-2.3.4.tar.gz",
"integrity": "sha256-...",
"strip_prefix": "mylib-2.3.4",
"overlay": {
"BUILD.bazel": "sha256-...",
"MODULE.bazel": "sha256-..."
},
"patch_strip": 1,
"patches": {
"0001_add_arm_support.patch": "sha256-...",
"0001_add_leg_support.patch": "sha256-...",
}
}
Now anyone using your local registry can depend on it with a simple bazel_dep:
bazel_dep(name = "mylib", version = "2.3.4.patched")
When to use this: When the dependency needs patches and isn’t in the BCR, but has a build system that’s reasonably portable (autotools, cmake, plain makefiles).
Strategy 4: Out-of-Band Building (The Last Resort)
Sometimes, you just can’t win. The dependency has a build system that’s too complex, too fragile, or too tied to specific toolchains. Maybe it requires specific compiler versions. Maybe it has a byzantine configure script, or was only ever meant to be built in an old mainframe (true story). Maybe it just doesn’t want to cooperate.
In these cases, you treat the dependency as an opaque artifact:
- Build it outside of Bazel using whatever ritual it requires
- Package the outputs (headers,
.sofiles, binaries) into an archive - Upload to somewhere accessible (cloud storage, GitHub releases, etc.)
- Add it to your registry pointing at the prebuilt artifact
Here’s what the registry entry looks like:
MODULE.bazel:
module(
name = "cursed_dependency",
version = "1.0.0.prebuilt",
)
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
name = "cursed_dependency_prebuilt",
build_file = "@cursed_dependency//:cursed_dependency.BUILD",
sha256 = "...",
urls = ["https://storage.example.com/cursed_dependency-1.0.0-linux-x86_64.tar.gz"],
)
cursed_dependency.BUILD:
cc_library(
name = "cursed_dependency",
hdrs = glob(["include/**/*.h"]),
includes = ["include"],
srcs = ["lib/libcursed.so"],
visibility = ["//visibility:public"],
)
When to use this: Only as a last resort. This method is the worst option because:
- You lose hermetic builds
- You lose cross-platform support (unless you build for every platform)
- You can’t easily see what changed between versions
- You’re on the hook for maintaining the build infrastructure
But sometimes, it’s the only option, as doing anything else would be just too expensive.
Picking the Right Strategy
Here’s my decision tree for every external dependency I encounter:
flowchart TD
Start[New Dependency] --> BCR{Is it in<br/>the BCR?}
BCR -->|Yes| BCRPatch{Do you need<br/>to patch it?}
BCRPatch -->|No| S1[Strategy 1:<br/>Use BCR]
BCRPatch -->|Yes| S1Custom[Strategy 1:<br/>Use BCR with<br/>local registry override]
BCR -->|No| Patch{Do you need<br/>to patch it?}
Patch -->|No| SysPkg{Is it available as<br/>a system package?}
SysPkg -->|Yes| BuildTime{Do you need it<br/>at build time?}
BuildTime -->|No| S2[Strategy 2:<br/>Use System Packages]
BuildTime -->|Yes| S3
SysPkg -->|No| S3
Patch -->|Yes| Port{Can you reasonably<br/>port its build<br/>to Bazel?}
Port -->|Yes| S3[Strategy 3:<br/>Port to Bazel]
Port -->|No| S4[Strategy 4:<br/>Out-of-Band Build<br/><i>but reconsider first</i>]
classDef strategy1 stroke:#333,stroke-width:2px
classDef strategy2 stroke:#333,stroke-width:2px
classDef strategy3 stroke:#333,stroke-width:2px
classDef strategy4 stroke:#333,stroke-width:2px
class S1 strategy1
class S2 strategy2
class S3 strategy3
class S4 strategy4
Astute readers will have seen that, often, the recommendation is “Port to Bazel, or pray that someone already has”. This is not an accident, but rather an unfortunate reality of Bazel: It works best when all code plays by its rules.
This is a big reason why I don’t think Bazel is the right choice for most organizations, but more on that on a later post.
Conclusion
That’s it for now.
These four strategies give you a framework for making consistent decisions about external dependencies. The hard part, of course, is still to really understand your dependency graph.
If you’re wrestling with similar dependency problems in your own Bazel migration, whether that’s missing BCR entries, painful patches, or dependencies that just won’t cooperate, I’d genuinely like to hear about it. You can reach me at borja@bytebard.software, or tell me about your project here.
And if you just enjoyed the read or have a pattern you’d like to see in a future ARBOR post, I’d love that too. Hearing from readers is the best motivation to keep these going.
– Borja
-
You can argue that 2 and 3 should be switched, and I would definitely see your point. But also, if you are arguing that, you probably know everything I’m going to say in this blog post. ↩︎