Step 4: Wrapped: make hello inside a Buck2 action¶
You now know what Buildroot builds (Step 1), how Buck2 decides what to run (Step 2), and how
buckroot turns a Buildroot project into a graph of Buck2 targets (Step 3). This page answers the remaining
question for the first of three variants: what actually runs when Buck2 builds the target for hello?
In the wrapped variant the answer is: Buildroot's own make hello. buckroot wraps Buildroot's build of a package in a
Buck2 action. Buck2 decides whether and when to run it; Buildroot still decides how. This is the variant that works for
every package of every project, and the one all the results tables use as the baseline.
The idea in one picture¶
flowchart LR
subgraph before[What Buck2 gives the action]
d[the dependencies' finished outputs]
c[a slice of the configuration]
s[the Buildroot source, only the parts this package may see]
end
before --> act["ACTION<br/>make hello<br/>(in a private room)"]
act --> out[one output file:<br/>hello.tar]
An action is a function: inputs in, one output file out. The output, hello.tar, holds everything make hello produced
for this package. The rest of this page opens the box.
Try it: build one package¶
From experiments/helloworld:
tools/buck2 build //buildroot-external/package/hello:hello --config br2.native= --show-full-output
--config br2.native= sets the list of native packages to empty, which is what makes it the wrapped variant.
Commands: 22 (cached: 0, remote: 0, local: 22)
BUILD SUCCEEDED
root//buildroot-external/package/hello:hello .../buildroot-external/package/hello/__hello__/hello.tar
Buck2 ran 22 actions (72 seconds in one measurement on a 4-core machine, longer when the machine is busy with something else). Why 22 if we asked for one target? Because hello
needs other things first, and those need things. buck2 log what-ran lists them:
| Count | Kind of action | What it does |
|---|---|---|
| 1 | br2_owners |
reads every Config.in in Buildroot and records which config option belongs to which package |
| 1 | br2_config |
turns the defconfig into the full .config |
| 10 | br2_slice |
cuts a per-package slice out of the .config (explained below) |
| 10 | br2_package |
runs make <package> for each of the ten packages hello needs, hello last |
The ten packages are hello's dependency closure: hello plus everything it needs directly or indirectly. The other
19 packages of the image (BusyBox, the filesystem tools) are not built, because hello does not need them. This is one
of the differences from Buildroot: ask Buck2 for one target and you get only its closure.
flowchart LR
hs[host-skeleton] --> hf[host-fakedate]
hf --> ski[skeleton-init-common]
ski --> sysv[skeleton-init-sysv]
sysv --> sk[skeleton]
sk --> mch[musl-compat-headers]
sk --> tcb[toolchain-external-bootlin]
tcb --> tce[toolchain-external]
mch --> tce
tce --> tc[toolchain]
tc --> hello
cfg[br2_config + br2_owners] -.slices.-> hello
(Every package also depends on host-skeleton and host-fakedate directly; those edges are left out. hello itself
depends on host-fakedate, host-skeleton, skeleton and toolchain.) The packages named skeleton* create the empty
directory layout of the filesystem; toolchain* unpack the prebuilt cross-compiler.
Opening the box: one action¶
Everything below is the action br2_package hello. Three questions: what does Buck2 think it is, what does it do inside,
and how is it kept honest.
What Buck2 sees¶
tools/buck2 aquery --output-all-attributes //buildroot-external/package/hello:hello
category br2_package
identifier hello
cmd python3 buck-out/.../br2/__pkg_action__/pkg_action.py package
--spec buck-out/.../hello/__hello__/package.spec.json
--out buck-out/.../hello/__hello__/output_artifacts/hello.tar
weight 1
allow_cache_upload true
The action is one command: run the Python script pkg_action.py, in "package" mode, giving it a spec and asking it to
write hello.tar. The spec is a JSON file: a plain-text description of the job, written by Buck2 from the target's
attributes. Everything the script needs to know is in the spec, and the spec's contents are inputs of the action. Change
any of it, and the action's key changes. This is the real spec for hello, with long lists shortened:
{
"pkg": "hello",
"stamp_dir": "build/hello-1.0",
"dl_dir": "hello",
"closure": [{"name": "host-skeleton", "tar": ".../host-skeleton.tar"}, {"name": "host-fakedate", "tar": "..."}, "..."],
"direct": {"toolchain": ["host-skeleton", "host-fakedate", "skeleton-init-common", "..."], "skeleton": ["..."]},
"dirs": ["buildroot-src/package/skeleton", "buildroot-src/package/fakedate", "...", "buildroot-src/toolchain/toolchain"],
"infra_src": [".b4-config", "Config.in", "Makefile", "arch", "board", "...", "toolchain/toolchain.mk", "utils"],
"infra_ext": ["external.desc", "external.mk"],
"common_dirs": ["common/hello"],
"common_src": {"hello": ["buck-out/.../common/hello/__src__/src"]},
"slice": "buck-out/.../hello/__hello__/8c82fd7bcaacee98/hello.slice.tar",
"symlinks": {"buildroot-src/system/skeleton/dev/fd": "../proc/self/fd", "...": "..."},
"ns": "buck-out/.../scripts/__br2-ns.sh__/br2-ns.sh",
"external": true
}
| Field | What it means |
|---|---|
closure |
the output files of every package hello needs (each a .tar, i.e. an archive of files), in build order: the dependencies' results |
direct |
each package's direct dependencies. Buildroot merges only those, so the action must know them |
dirs |
the directories of those packages in the Buildroot tree (their makefiles and patches): what the action is allowed to read |
infra_src, infra_ext |
the rest of the Buildroot tree that is not a package directory: the core makefiles, the architecture files, and so on |
common_dirs, common_src |
hello's own source, as a declared input |
slice |
this package's slice of the configuration (below) |
symlinks |
some symbolic links in the source tree point nowhere. Buck2 cannot see those files, so they are recorded here as data and recreated |
ns |
the script that creates the private room (below) |
The private room: a "namespace"¶
The action does not run make directly. It runs it inside a private mount namespace. In plain terms: Linux can give a
process its own private view of the filesystem. The process sees a folder tree we construct for it; changes it makes do not
leak to the rest of the machine, and it sees nothing we did not put there. The script scripts/br2-ns.sh builds this room.
Inside, Buildroot's inputs are always at the same fixed paths, whatever your checkout path is:
| Path | Contents | Can the build write here? |
|---|---|---|
/mnt/src |
the Buildroot source, but only the parts this package is allowed to see | no (read-only) |
/mnt/external |
the BR2_EXTERNAL tree (hello's own package), same rule |
no |
/mnt/common |
hello's source code |
no |
/mnt/dl |
the download directory | yes |
/mnt/out |
the Buildroot output directory | yes |
Why go to this trouble? Two reasons.
Reason 1: the paths get baked into the output. Buildroot builds embed their build paths in the files they produce. For
example, the toolchain's helper file usr/lib/libstdc++.so.6.0.32-gdb.py contains the absolute path of the directory it was
built in. If you check out the project at /home/anna/work and your colleague at /srv/ci/build42, the two images would
differ, and Buck2 would see two different results for the same inputs; a cache would never hit across machines. With the room,
every build runs at /mnt/..., so the results are identical everywhere.
Reason 2: it enforces honesty. Remember from Step 2: Buck2 can only be correct if an action reads only its
declared inputs. Buck2 does not stop a local action from reading other files, though. The room does. This is what the
hello action's room looks like (real output):
$ ls /mnt/src/package # what hello may read of Buildroot's package directory
Config.in Config.in.host Makefile.in doc-asciidoc.mk fakedate musl-compat-headers
pkg-autotools.mk pkg-cargo.mk ... pkg-waf.mk skeleton skeleton-init-common skeleton-init-sysv
$ ls /mnt/src/package/busybox # BusyBox exists on disk, but hello does not depend on it
ls: cannot access '/mnt/src/package/busybox': No such file or directory
$ touch /mnt/src/package/skeleton/x # writing into the source
touch: cannot touch '/mnt/src/package/skeleton/x': Read-only file system
$ ls -A /mnt/src/.git # the git history is hidden
ls: cannot access '/mnt/src/.git': No such file or directory
$ cat /proc/net/dev # the network: only the loopback exists
lo
If hello's makefile secretly read the BusyBox package's files, make would fail with "No such file or directory", loudly, the
first time, instead of quietly producing something that depends on BusyBox without Buck2 knowing. (.git is hidden
because Buildroot asks git for a version string that would otherwise depend on whether your work tree has uncommitted
changes. The network is off because every download is a declared input.)
The view: which parts of the source hello may see¶
The list of what to show in the room is the action's view. For hello (scripts/show-inputs.py hello):
hello: closure = host-fakedate, host-skeleton, musl-compat-headers, skeleton, skeleton-init-common,
skeleton-init-sysv, toolchain, toolchain-external, toolchain-external-bootlin
== /mnt/src (64 entries)
.b4-config Config.in Makefile arch/ board/ boot/ configs/ docs/ fs/ linux/ support/ system/ utils/
package/Config.in package/Makefile.in package/pkg-autotools.mk package/pkg-cmake.mk ... package/pkg-waf.mk
package/fakedate/ package/musl-compat-headers/ package/skeleton/ package/skeleton-init-common/ ...
toolchain/toolchain/ toolchain/toolchain-external/ toolchain/toolchain-buildroot/ toolchain/toolchain.mk
== /mnt/external (3 entries)
external.desc external.mk package/hello/
== /mnt/common (1 entries)
hello/
Buildroot's core makefile finds packages with one line, include $(sort $(wildcard package/*/*.mk)): "include every
package's makefile that exists". Packages missing from the view are simply not there to include, so make still starts
fine with 64 entries instead of the whole tree. The very same list is what Buck2 is told are the action's inputs, so what
is declared and what is visible cannot drift apart.
The configuration slice¶
Step 1 introduced the .config: about 4,100 lines of options. If every package's action depended on the whole file, then
changing any one option (say, a BusyBox setting) would change every action's key and rebuild everything. So each package
gets a slice: only the lines it can be affected by.
hello's slice is 245 of the 4,130 lines. Of the 158 BR2_PACKAGE_* lines in the full file (which say which packages
are on and how they are set) hello's slice keeps 26: those of the packages in its view. It has none of BusyBox's own
settings. It starts like this:
BR2_HAVE_DOT_CONFIG=y
BR2_EXTERNAL_NAMES="BUCKROOT_HELLOWORLD"
BR2_EXTERNAL_BUCKROOT_HELLOWORLD_PATH="/mnt/external"
BR2_HOST_GCC_AT_LEAST_4_9=y
...
BR2_aarch64=y
BR2_ARCH="aarch64"
BR2_GCC_TARGET_CPU="cortex-a53"
BR2_TOOLCHAIN_EXTERNAL=y
BR2_TOOLCHAIN_EXTERNAL_BOOTLIN=y
BR2_TOOLCHAIN_EXTERNAL_BOOTLIN_AARCH64_MUSL_STABLE=y
The rule is: keep an option if it is global (CPU, toolchain, system settings: everything can be affected), or it belongs to
a package in the view, or the package's own makefile mentions it, or the core makefiles read it. br2_owners (the first
action above) is what knows which option belongs to which package. The slice is written as a byte-for-byte reproducible
archive, so if a config change does not alter hello's slice, the slice's hash is unchanged, and Buck2 sees "same input".
Here is that in action. Add one BusyBox-only option to the defconfig and rebuild hello:
$ echo 'BR2_PACKAGE_BUSYBOX_SHOW_OTHERS=y' >> buildroot-external/configs/helloworld_qemu_aarch64_musl_rootfs_defconfig
$ tools/buck2 build //buildroot-external/package/hello:hello --config br2.native=
Commands: 12 (cached: 0, remote: 0, local: 12) real 4.2 s
Twelve actions ran: br2_owners, br2_config, and ten br2_slice. No package action ran, none of the ten makes.
Each slice was recomputed, but hello's (and every other package in its closure) came out the same, so Buck2 stopped there.
Compare with the first build: 22 actions, over a minute. The same edit in plain Buildroot would need you to know which
packages to rebuild by hand.
What the action does inside¶
pkg_action.py works in a scratch directory, /var/tmp/buckroot-work/br2-hello-XXXX/, outside the project because
Buildroot trees confuse Buck2's file watcher. It is deleted when the action ends. With BR2_KEEP_WORK=1 it stays, so you can
look, and you can run the action by hand:
S=buck-out/v2/art/root/5fe249fa9992182d/buildroot-external/package/hello/__hello__/package.spec.json
BR2_KEEP_WORK=1 python3 br2/pkg_action.py package --spec $S --out /tmp/hello.tar
[pkg_action] make hello
[pkg_action] kept /var/tmp/buckroot-work/br2-hello-cfwpklgt (7 seconds)
br2-hello-cfwpklgt/
out/ the Buildroot output directory: .config, build/, per-package/, images/ (Step 1)
dl/hello/ downloads (none: hello's source is local)
common-root/ the declared local sources, assembled
overlay/ symbolic links recreated from spec["symlinks"]
view-src.txt the list of what to show at /mnt/src \
view-ext.txt ... at /mnt/external > the views
view-common.txt ... at /mnt/common /
make.log everything make printed
The script does four things, in this order.
1. Seed the output directory. Buildroot's make normally builds a whole system. Here we want only hello, so we set
things up as if all its dependencies were already built. The script unpacks the configuration slice into out/.config, and
creates the stamp files (Step 1) of every package in the closure. make hello looks at the stamps, sees its dependencies
are "done", and does not try to build them.
2. Reconstruct the dependencies' trees. Recall per-package directories: each package has its own host/ and target/
tree, built by merging the trees of its direct dependencies. hello.tar and its siblings hold only what each package
added (its delta), not the whole tree, because a complete tree includes the entire 362 MB toolchain and storing that
in every package's output would be absurd. So the script rebuilds each dependency's complete tree by overlaying its
closure's deltas in order, using hard links (a hard link is a second name for a file, not a second copy, so overlaying
hundreds of megabytes costs almost nothing).
3. Run make hello in the room. This is the only line that runs Buildroot.
4. Pack the delta. Everything make created for hello (its per-package/hello/ tree and its build/hello-1.0/
stamps) that was not seeded goes into hello.tar.
What make printed¶
The real make.log, trimmed:
make: Entering directory '/mnt/src'
>>> hello 1.0 Syncing from source dir /mnt/external/../common/hello
rsync -au --chmod=u=rwX,go=rX --exclude .svn --exclude .git ... /mnt/external/../common/hello/ /mnt/out/build/hello-1.0
>>> hello 1.0 Configuring
mkdir -p /mnt/out/per-package/hello/host
rsync -a --hard-links --link-dest=.../per-package/host-fakedate/host/ .../host-fakedate/host/ /mnt/out/per-package/hello/host
rsync -a --hard-links --link-dest=.../per-package/host-skeleton/host/ ...
rsync -a --hard-links --link-dest=.../per-package/skeleton/host/ ...
rsync -a --hard-links --link-dest=.../per-package/toolchain/host/ ...
mkdir -p /mnt/out/per-package/hello/target
rsync ... (the same four, for target/)
>>> hello 1.0 Building
/mnt/out/per-package/hello/host/bin/aarch64-linux-gcc -D_LARGEFILE_SOURCE -D_LARGEFILE64_SOURCE -D_FILE_OFFSET_BITS=64 -O2 -g0
-o /mnt/out/build/hello-1.0/hello /mnt/out/build/hello-1.0/hello.c
>>> hello 1.0 Installing to target
/usr/bin/install -D -m 0755 /mnt/out/build/hello-1.0/hello /mnt/out/per-package/hello/target/usr/bin/hello
make: Leaving directory '/mnt/src'
Read it against hello.mk from Step 1:
- Syncing:
HELLO_SITE_METHOD = local, so "download and extract" isrsync, a copy tool, copyingcommon/hello. - Configuring:
hellohas no configure script. What Buildroot does at this step for every package with per-package directories is merge the direct dependencies'host/andtarget/trees into the package's own: the eightrsynclines. That is howhellogets a compiler. - Building:
HELLO_BUILD_CMDS, with the variables expanded.$(TARGET_CC)became the cross-compileraarch64-linux-gcc, found in the mergedhost/bin.$(TARGET_CFLAGS)became-D_LARGEFILE_SOURCE ... -O2 -g0. - Installing to target:
HELLO_INSTALL_TARGET_CMDS, with$(TARGET_DIR)expanded to this package's owntarget/.
The output: hello.tar¶
hello.tar is an archive (a tar file: many files packed into one) with twelve regular files:
per-package/hello/target/usr/bin/hello the program (8,392 bytes)
per-package/hello/host/aarch64-buildroot-linux-musl/sysroot/usr/share/buildroot/gdbinit
per-package/hello/host/aarch64-buildroot-linux-musl/sysroot/usr/lib/libstdc++.so.6.0.32-gdb.py
build/hello-1.0/.stamp_rsynced .stamp_configured .stamp_built .stamp_target_installed .stamp_installed
build/hello-1.0/.files-list.txt (22 bytes: what hello installed)
build/hello-1.0/.files-list-host.txt .files-list-staging.txt .files-list-images.txt (empty)
Three kinds of content: the program itself; the stamps, which the next package's action will seed to convince make
that hello is done; and .files-list files, Buildroot's record of what the package installed. The two sysroot files are
rewritten by Buildroot for every package that depends on the toolchain, so each package's tree has its own paths in them;
they are harmless, and the native variant does not reproduce them.
The rest of the build¶
Building //:rootfs (instead of just hello) adds the other 19 packages (BusyBox, e2fsprogs, util-linux, the host
tools) and one more action, br2_rootfs: it unpacks every package's tar, runs Buildroot's finalize step (merge every
package's target/ into one tree, apply the setup scripts) and packs the image rootfs.ext4 (Step 1). 61 actions in
total. Then buckroot compares the image's manifest with the golden's:
scripts/br2 build --variant wrapped --mode local
{"variant": "wrapped", "mode": "local",
"cold": {"ok": true, "seconds": 338.0, "commands": 61, "cached": 0, "remote": 0, "local": 61, "manifest": "IDENTICAL"}}
"manifest": "IDENTICAL" is the goal: the image Buck2 built matches Buildroot's own, path for path and hash for hash.
What reruns when something changes¶
Edit hello.c: put a comment at the top, and build again.
$ tools/buck2 build //buildroot-external/package/hello:hello --config br2.native=
Commands: 1 (cached: 0, remote: 0, local: 1) real 12.4 s
root//buildroot-external/package/hello:hello (br2_package hello)
One action reran: hello itself (12 seconds here; running it by hand takes 7). The ten br2_slice actions and the nine
dependency packages did not: none of their inputs changed. The image itself would rerun the finalize step.
That is the wrapped granularity: the whole package is the smallest thing Buck2 can skip or redo. For a 12-second package,
fine. If hello were the Linux kernel, the same one-line change would rerun the whole kernel build. Step 5 and
Step 6 are about doing better.
Cache and remote execution¶
Because an action is "spec plus scripts plus the declared inputs, run in a room", the same action can run somewhere else. With
--config br2.remote_execution=true, Buck2 uploads the action's input tree to the Buildbarn server and a worker runs the
same command in the same kind of room. With --config br2.remote_cache=true, Buck2 first asks the cache whether the action key
was seen before. Everything the action needs is in its inputs and in the worker's tool image (the compilers and utilities the
makefiles call, pinned in toolkit/infra/buildbarn/runner/Dockerfile), so the result is the same. Measured on this project,
wrapped variant, all 61 actions:
| Mode | Cold (nothing cached) | Warm (after buck2 clean, from the cache) |
|---|---|---|
| local | 338 s | not applicable |
| local-cache | 364 s | 8.5 s (61 of 61 from cache) |
| remote | 363 s | not applicable |
| remote-cache | 417 s | 9.0 s (61 of 61 from cache) |
See Remote cache and execution for how the server is set up and what went wrong along the way.
What wrapped cannot do¶
- Skip inside a package. One changed input reruns the whole
make, including parts that did not need to run. - Guarantee anything about the host tools
makecalls (gcc,perl,rsync): they are not part of the action key. Remote execution pins them in the worker image; a local run uses whatever your machine has.
Step 5 proposes splitting the make into its steps; Step 6 removes make altogether.