-
Notifications
You must be signed in to change notification settings - Fork 1
Lesson 14: Modifying The Source Tree
At the end of Lesson 13, we needed to perform several cleanup tasks.
The tasks resulted in a more consistent organizational structure and better documentation for end users; however, they did take manual effort.
In Lesson 8, we learned how to programmatically manipulate both BUILD and .bzl files.
Today, we'll try to build our own tools that manipulate the source tree.
In Lesson 9, we considered using macros/graphviz.bzl to automatically write the PNGs for graphs back to the source tree.
We did not choose to follow that path.
As a reminder, here's what we learned about at that time:
This is one of the core philosophical differences between Bazel and other build tools you may have used in the past- modifying the current workspace or module as a side-effect of evaluating rules is actively discouraged. One of Bazel's core principles is that builds must be reproducible. Modifying the current workspace or module as a side-effect would violate that principle, because each invocation of
bazel buildorbazel testwould modify the source tree, causing future instances of Bazel commands to produce different results. Violations like these, or embedding current timestamps/dates into build artifacts, limit how reproducible, and by extension how correct, our builds are. Bazel's philosophy prefers solutions with the following properties in this order:
- Correctness: Builds must be correct. For a given source tree, the output of the same build should always be the same, regardless of what the output tree looks like.
- Throughput: The build system must strive to consistently produce consumable results, especially when utilizing shared computational resources.
- Ease of Use: When multiple implementations are equally correct and have similar throughput profiles, prefer the solution that is easier to use.
- Latency: All other things being equal, prefer the solution that arrives at output earlier.
Ultimately, users should always be able to run
bazel build //...andbazel test //...without any previous invocations or local state present, without needing multiple invocations or manual intervention. Developers and repositories may choose to adopt other tools and scripts which do modify the source tree, but they are considered to be outside of Bazel's scope- for example,buildozer.
Bazel's official documentation denotes this as well:
A project should always be able to run
bazel build //...andbazel test //...successfully on its stable branch.
So, how do we build and execute programs that modify our source tree with Bazel?
The answer is with the run command, which is syntactic sugar for building and then executing a program.
While the process of building an artifact can't modify the source tree, there is nothing out-of-bounds about building an executable program to modify Bazel repositories, like buildozer or buildifier, with Bazel- In fact, both tools are built in a shared Bazel repository.
In Lesson 11, we wrote a runnable program that showed us where our Bazel programs actually executed by default. Let's rerun that script:
$ bazelisk run //toolchains/clojure:pwd
INFO: Analyzed target //toolchains/clojure:pwd (95 packages loaded, 4318 targets configured).
INFO: Found 1 target...
Target //toolchains/clojure:pwd up-to-date:
bazel-bin/toolchains/clojure/pwd
INFO: Elapsed time: 8.203s, Critical Path: 0.02s
INFO: 1 process: 7 action cache hit, 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/toolchains/clojure/pwd
{:directory /private/var/tmp/_bazel_nnichols/eed0c1f29a947988aa70f35897692932/execroot/_main/bazel-out/darwin_arm64-fastbuild/bin/toolchains/clojure/pwd.runfiles/_main}Let's refer to Bazel's documentation and break down what this path means.
-
/private/var/tmp/- This is the
outputRoot, and is the parent-most folder for the state of Bazel output. The exact path depends user configuration, the host operating system, and the exact version(s) of Bazel in use.
- This is the
-
_bazel_nnichols/- This is the
outputUserRoot: a subdirectory of theoutputRootfor a specific user. Bazel can share theoutputRootacross multiple users of the same directory space.
- This is the
-
eed0c1f29a947988aa70f35897692932/- This is the
outputBase: the value is a hash of the workspace root. It will naturally change as the repository's state changes.
- This is the
-
execroot/- This is the working directory for all actions. All action inputs and outputs are referenced relative to this directory.
-
_main/- This is the root of the working tree for
bazel build
- This is the root of the working tree for
-
bazel-out/- This is the
outputPathand is where the actual build output is written to. The top-levelbazel-outsymlink in our repository points to this location.
- This is the
-
darwin_arm64-fastbuild/- This subdirectory is unique per
BuildConfigurationand can contain information about the host machine's operating system and CPU architecture. This path is used to separate build artifacts with different platform dependencies.
- This subdirectory is unique per
-
bin/- This is a subfolder for binary output artifacts.
For example, those produced by building
clojure_binaryorjava_binaryrule targets.
- This is a subfolder for binary output artifacts.
For example, those produced by building
-
toolchains/clojure/pwd.runfiles/- This is the
runfilessymlink. It contains the content specified by therunfilesattribute of theDefaultInfoprovider in the implementation of ourclojure_binaryrule.
- This is the
-
_main- This is the directory in which the shell script that executes our binary evaluates.
In this path, we see that many subdirectories contain information that can and will change over time. If we updated our program to traverse this path upwards, we would need to handle multiple, fragile routes. It would need to consider:
- The current version of Bazel
- The current user executing the command
- The SHA of the repository
- The host machine's operating system and CPU architecture
- The package containing the rule target we're currently evaluating
- Any configuration supplied to the Bazel CLI through
.bazelrcfiles or command line flags
Navigating these permutations correctly would be difficult and brittle.
Thankfully, Bazel provides information about the workspace to binaries executed with bazel run.
Let's update our clojure_binary rule to consider the environment variable BUILD_WORKSPACE_DIRECTORY.
We'll want to surface a new attribute to control where the binary executes:
"""
Defines the clojure_binary rule, which makes clojure scripts executable via `bazelisk run`
"""
def _clojure_binary_impl(ctx):
toolchain = ctx.toolchains["//:clojure_toolchain"]
classpath = ctx.actions.declare_file("%s.classpath" % ctx.label.name)
# The compiler has direct dependencies satisfied by the toolchain
# The generated classpath has transitive dependencies of what the underlying code requires
deps = depset(
direct = toolchain.files.runtime,
transitive = [dep[JavaInfo].transitive_runtime_jars for dep in ctx.attr.deps],
)
# Write a classpath file containing all of our code dependencies and our static resources
ctx.actions.write(
output = classpath,
content = ":".join(
[f.short_path for f in deps.to_list()] +
[f.dirname for f in ctx.files.data],
),
)
ctx.actions.write(
output = ctx.outputs.executable,
content = """
set -ex
BAZEL_ROOT=$(pwd)
# The Python-casing of booleans is shared by Starlark
if [[ {execute_in_workspace} == "True" ]]; then
cd "$BUILD_WORKSPACE_DIRECTORY"
fi
FULL_CLASSPATH_FILE="$BAZEL_ROOT/full_classpath"
sed -E "s=(^|:)=\\1$BAZEL_ROOT/=g" $BAZEL_ROOT/{classpath} > $FULL_CLASSPATH_FILE
{java} \
-XX:-OmitStackTraceInFastThrow \
-classpath @$FULL_CLASSPATH_FILE \
clojure.main \
-m {main} \
{data} \
{arguments} $@
""".format(
java = toolchain.java_runfiles,
classpath = classpath.short_path,
main = ctx.attr.main,
data = "--data {}".format(":".join([f.short_path for f in ctx.files.data])) if ctx.files.data else "",
arguments = " ".join(ctx.attr.arguments),
execute_in_workspace = ctx.attr.execute_in_workspace,
),
)
return DefaultInfo(
runfiles = ctx.runfiles(
files = ctx.files.data +
toolchain.files.jdk +
[classpath],
transitive_files = deps,
),
)
clojure_binary = rule(
doc = "Executes a clojure program as a runnable script",
implementation = _clojure_binary_impl,
executable = True,
attrs = {
"main": attr.string(
mandatory = True,
doc = "The namespace whose -main function is the target for execution.",
),
"deps": attr.label_list(
mandatory = True,
allow_empty = False,
providers = [JavaInfo],
doc = "Libraries to link as dependencies of this binary.",
),
"arguments": attr.string_list(
default = [],
doc = "A list of string arguments to pass to our script.",
),
"data": attr.label_list(
allow_files = True,
doc = "Static data dependencies required at runtime",
),
"execute_in_workspace": attr.bool(
default = False,
doc = "A boolean that determines if the script should execute in the workspace or the execRoot.",
),
},
toolchains = ["//:clojure_toolchain"],
)The new execute_in_workspace attribute will tell us to cd into the directory referenced by BUILD_WORKSPACE_DIRECTORY.
To test it out, we'll need a new rule target in toolchains/clojure/BUILD:
clojure_binary(
name = "pwd_workspace",
execute_in_workspace = True,
main = "toolchains.clojure.pwd",
tags = [clojure_tag],
deps = [":pwd_source"],
)Let's try it out:
$ bazelisk run //toolchains/clojure:pwd_workspace
INFO: Analyzed target //toolchains/clojure:pwd_workspace (95 packages loaded, 4318 targets configured).
INFO: Found 1 target...
Target //toolchains/clojure:pwd_workspace up-to-date:
bazel-bin/toolchains/clojure/pwd_workspace
INFO: Elapsed time: 7.936s, Critical Path: 0.02s
INFO: 2 processes: 5 action cache hit, 2 internal.
INFO: Build completed successfully, 2 total actions
INFO: Running command line: bazel-bin/toolchains/clojure/pwd_workspace
java: No such file or directoryThe command fails. To understand what happened, let's try running our original rule target:
$ bazelisk run //toolchains/clojure:pwd_workspace
INFO: Analyzed target //toolchains/clojure:pwd (0 packages loaded, 1 target configured).
INFO: Found 1 target...
Target //toolchains/clojure:pwd up-to-date:
bazel-bin/toolchains/clojure/pwd
INFO: Elapsed time: 0.107s, Critical Path: 0.00s
INFO: 1 process: 6 action cache hit, 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/toolchains/clojure/pwd
{:directory /private/var/tmp/_bazel_nnichols/eed0c1f29a947988aa70f35897692932/execroot/_main/bazel-out/darwin_arm64-fastbuild/bin/toolchains/clojure/pwd.runfiles/_main}So, what's the difference between the two?
The Java executable our clojure toolchain is built on top of is loaded into the runfiles of our binary rule target.
By leaving that directory, the relative path to our dependency changed.
To resolve that, we'll need to use another environment variable, which refers to the full path we see when we execute //toolchains/clojure:pwd_workspace.
That path is supplied through the environment variable BAZEL_ROOT.
Our rule now looks like:
"""
Defines the clojure_binary rule, which makes clojure scripts executable via `bazelisk run`
"""
def _clojure_binary_impl(ctx):
toolchain = ctx.toolchains["//:clojure_toolchain"]
classpath = ctx.actions.declare_file("%s.classpath" % ctx.label.name)
# The compiler has direct dependencies satisfied by the toolchain
# The generated classpath has transitive dependencies of what the underlying code requires
deps = depset(
direct = toolchain.files.runtime,
transitive = [dep[JavaInfo].transitive_runtime_jars for dep in ctx.attr.deps],
)
# Write a classpath file containing all of our code dependencies and our static resources
ctx.actions.write(
output = classpath,
content = ":".join(
[f.short_path for f in deps.to_list()] +
[f.dirname for f in ctx.files.data],
),
)
ctx.actions.write(
output = ctx.outputs.executable,
content = """
set -ex
BAZEL_ROOT=$(pwd)
# The Python-casing of booleans is shared by Starlark
if [[ {execute_in_workspace} == "True" ]]; then
cd "$BUILD_WORKSPACE_DIRECTORY"
fi
FULL_CLASSPATH_FILE="$BAZEL_ROOT/full_classpath"
sed -E "s=(^|:)=\\1$BAZEL_ROOT/=g" $BAZEL_ROOT/{classpath} > $FULL_CLASSPATH_FILE
# We might have navigated outside of the execRoot, so we need to supply an absolute path for the Java executable
$BAZEL_ROOT/{java} \
-XX:-OmitStackTraceInFastThrow \
-classpath @$FULL_CLASSPATH_FILE \
clojure.main \
-m {main} \
{data} \
{arguments} $@
""".format(
java = toolchain.java_runfiles,
classpath = classpath.short_path,
main = ctx.attr.main,
data = "--data {}".format(":".join([f.short_path for f in ctx.files.data])) if ctx.files.data else "",
arguments = " ".join(ctx.attr.arguments),
execute_in_workspace = ctx.attr.execute_in_workspace,
),
)
return DefaultInfo(
runfiles = ctx.runfiles(
files = ctx.files.data +
toolchain.files.jdk +
[classpath],
transitive_files = deps,
),
)
clojure_binary = rule(
doc = "Executes a clojure program as a runnable script",
implementation = _clojure_binary_impl,
executable = True,
attrs = {
"main": attr.string(
mandatory = True,
doc = "The namespace whose -main function is the target for execution.",
),
"deps": attr.label_list(
mandatory = True,
allow_empty = False,
providers = [JavaInfo],
doc = "Libraries to link as dependencies of this binary.",
),
"arguments": attr.string_list(
default = [],
doc = "A list of string arguments to pass to our script.",
),
"data": attr.label_list(
allow_files = True,
doc = "Static data dependencies required at runtime",
),
"execute_in_workspace": attr.bool(
default = False,
doc = "A boolean that determines if the script should execute in the workspace or the execRoot.",
),
},
toolchains = ["//:clojure_toolchain"],
)Now we should be able to see the directory our source code is located in:
$ bazelisk run //toolchains/clojure:pwd_workspace
INFO: Analyzed target //toolchains/clojure:pwd_workspace (1 packages loaded, 11 targets configured).
INFO: Found 1 target...
Target //toolchains/clojure:pwd_workspace up-to-date:
bazel-bin/toolchains/clojure/pwd_workspace
INFO: Elapsed time: 0.116s, Critical Path: 0.00s
INFO: 2 processes: 5 action cache hit, 2 internal.
INFO: Build completed successfully, 2 total actions
INFO: Running command line: bazel-bin/toolchains/clojure/pwd_workspace
+
{:directory ~/bite-sized-bazel}Now that we have a reference to the current directory, let's do something useful with that.
For the last two Lessons, we've been using Stardoc to generate API documentation for rules, macros, providers, and aspects.
The content of this documentation is helpful, but as it stands, we have to build the documentation and find the appropriate artifacts to view them.
Let's make the documentation part of the repository's source code.
We'll write a new clojure_binary rule target to do that.
touch doc_hub/copy.cljAnd we'll introduce the following code:
(ns doc-hub.copy
"Copies files from the execRoot into the workspace"
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[clojure.tools.cli :as cli]))
(def create-file
"Create a file, and, if necessary, all of its parent directories.
Similar to chaining `mkdir -p` and `touch`"
(comp io/make-parents io/file))
(def bazel-artifact-directory
"bazel-bin/")
(defn copy-file
"Copies the artifact from `source-file` to `output`"
[source-file output]
(let [source-filename (str bazel-artifact-directory source-file)
content (slurp source-filename)
destination-filename (str output)]
(println (format "Copying %s to %s" source-filename destination-filename))
(create-file destination-filename)
(spit destination-filename content)))
(defn -main
[& args]
(let [cli-arguments (cli/parse-opts args
;; the `--data` flag comes from `clojure_binary` rule
[["-data FILE"
"--data FILE"
"A file, relative to where Bazel artifacts are generated, which needs to be copied."
:default ""]
["-output FILE"
"--output FILES"
"The filepath where the artifact should be copied to. Relative to the workspace directory."
:default ""]])
source-filepath (get-in cli-arguments [:options :data])
output-filepath (get-in cli-arguments [:options :output])]
(assert (not (str/blank? source-filepath)))
(assert (not (str/blank? output-filepath)))
(copy-file source-filepath output-filepath)))In the above, we'll pass references to a built artifact and a path to copy the content to.
To try the rule out, we'll need to update duc_hub's BUILD file.
First, we'll load the clojure_library and clojure_binary rules:
load("//toolchains/clojure:clojure_binary.bzl", "clojure_binary")
load("//toolchains/clojure:clojure_library.bzl", "clojure_library")Then, we'll define a rule target to package our copy.clj file into a JAR:
clojure_library(
name = "copy",
srcs = [
"copy.clj",
],
)And we'll execute the main method of that JAR through a clojure_binary.
That program will need a handle on the API documentation we've concatenated together, so we'll add :concated_docs as a data attribute:
clojure_binary(
name = "copy_api_documentation",
arguments = [
"--output",
"./generated/api-documentation.md",
],
data = [
":concated_docs",
],
execute_in_workspace = True,
main = "doc-hub.copy",
deps = [
":copy",
],
)We now have a target we can exercise with run to copy our documentation back to our source code.
Let's try it out:
$ bazelisk run //doc_hub:copy_api_documentation
INFO: Analyzed target //doc_hub:copy_api_documentation (172 packages loaded, 5717 targets configured).
INFO: Found 1 target...
Target //doc_hub:copy_api_documentation up-to-date:
bazel-bin/doc_hub/copy_api_documentation
INFO: Elapsed time: 8.397s, Critical Path: 0.10s
INFO: 2 processes: 960 action cache hit, 2 internal.
INFO: Build completed successfully, 2 total actions
INFO: Running command line: bazel-bin/doc_hub/copy_api_documentation
Copying bazel-bin/doc_hub/build_api.md to ./generated/api-documentation.mdIf we want to verify the contents, we can:
less ./generated/api-documentation.mdIn the previous example, our rule target was explicitly executed from the workspace directory.
A natural question to ask is "What if we didn't set that attribute?"
Let's try it out by adding another clojure_binary rule target:
clojure_binary(
name = "copy_api_documentation_local",
arguments = [
"--output",
"./generated/api-documentation.md",
],
data = [
":concated_docs",
],
execute_in_workspace = False,
main = "doc-hub.copy",
deps = [
":copy",
],
)And we'll now run that target:
$ bazelisk run //doc_hub:copy_api_documentation_local
Execution error (FileNotFoundException) at java.io.FileInputStream/open0 (FileInputStream.java:-2).
bazel-bin/doc_hub/build_api.md (No such file or directory)
Full report at:
/var/folders/bn/4w6xpwps6f149dfv3z87z4tw0000gp/T/clojure-18312290384586490433.ednThe most important part of the output bazel-bin/doc_hub/build_api.md (No such file or directory) is an important reminder that the execution root and the workspace have distinct directory layouts.
In the workspace, we have multiple convenience symlinks available to us- and our script depends upon them.
While writing tools that integrate with output produced by Bazel, it's important to know and declare where we expect the code to run.
It's also important to note that these paths and conventions could change in future versions of Bazel.
Earlier in this lesson, we highlighted a line from Bazel's official documentation:
A project should always be able to run
bazel build //...andbazel test //...successfully on its stable branch.
We're now in a state where we can use the Bazel CLI to potentially modify the workspace prior to invoking bazel build //... or bazel test //....
However, that would undermine the spirit of the recommendation.
For our scripts to adhere to this guidance, we should:
- Only depend upon artifacts that have been built by Bazel.
- Thankfully,
bazelisk runwill build all dependencies prior to execution.
- Thankfully,
- Produce content which is generally exists outside of the dependency graph
For the second bullet point, there is an additional configuration file we can add to the repository: .bazelignore
Let's try it out:
touch .bazelignore
echo "generated/" >> .bazelignoreTo see it in practice, we'll try to query for all of the content in the generated/ directory:
$ bazelisk query "//generated:*"
ERROR: no such package 'generated': Package is considered deleted due to --deleted_packagesThe .bazelignore file configures Bazel to be aware that generated/ is a directory, but is not a valid package.
Even if we add a BUILD file to this directory, Bazel will ignore it.
touch generated/BUILDAny rule target in this package will be ignored:
"""
This BUILD file is unreachable and is ignored by Bazel.
The configuration controlling that behavior lives in `.bazelignore`
"""
filegroup(
name = "bazel_ignore_test",
srcs = [
"api-documentation.md",
],
)Which we can confirm with build:
$ bazelisk build //generated:bazel_ignore_test
WARNING: Target pattern parsing failed.
ERROR: Skipping '//generated:bazel_ignore_test': no such package 'generated': Package is considered deleted due to --deleted_packages
ERROR: no such package 'generated': Package is considered deleted due to --deleted_packages
INFO: Elapsed time: 6.511s
INFO: 0 processes.
ERROR: Build did NOT complete successfullyGoing forward, we'll need to be intentional about where our programs execute and what is part of the source tree.
Most of our changes exist outside of the dependency graph, so we won't see many differences since the last Lesson. That said, it is an important visual aid to help us understand our progress:
bazelisk query "//..." --output=graph > visualizations/src/source_tree_graph.gv
dot -Tpng < visualizations/src/source_tree_graph.gv > visualizations/out/source_tree_graph.pngWhich renders as:
As always, you can view the changes from this Lesson on GitHub.
This repository, documentation, and all included code is licensed under the MIT License. See the LICENSE file for more details.
Please consider supporting this repository.
Return to the repository here.
- Home
- Glossary
- Further Reading
- Lessons
- Lesson 1: Installing Bazel
- Lesson 2: Creating Packages
- Lesson 3: Building Artifacts
- Lesson 4: Dependency Management
- Lesson 5: Executing Tests
- Lesson 6: General Rules
- Lesson 7: Authoring Rules
- Lesson 8: Build Tools
- Lesson 9: Symbolic Macros
- Lesson 10: Toolchains
- Lesson 11: Advanced Toolchains
- Lesson 12: Stardoc
- Lesson 13: Aspects
- Lesson 14: Modifying The Source Tree
- Repository States
