Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Recipes in Nickel

Writing a request by hand is tedious: a real target pulls in hundreds of packages — each a source plus a build recipe — all wired together by node id. Recipes are instead written in Nickel, a typed configuration language, and lowered to a request. The bobr-recipes repository is a worked example: a Nickel program that builds a whole Linux root filesystem from source, starting from an OCI base image, bootstrapping a self-hosted toolchain, and composing the result into root filesystems and disk images.

bobr itself never sees Nickel — it only receives the JSON request the Nickel layer produces. This chapter describes how that layer works in bobr-recipes.

The package set

bobrpkgs.ncl exports a function from an ordered list of overlays to the recipe set; (import "bobrpkgs.ncl") [] builds the default set. It folds together the package recipes — one module per package, listed in pkgs/pkgs.ncl and living beside it in pkgs/ — with the modules that assemble images, tests, and bundles. Each module is a function of the finished set, so recipes refer to one another by name — pkgs.gcc, pkgs.glibc_libs, pkgs.base_filesystem. The set is a fixed point: every package sees the fully assembled pkgs, which is what lets recipes depend on each other and lets overlays override anything.

A package module returns named recipes:

fun pkgs =>
  let libffi_src = {
    version | default = "3.6.0",
    name = "libffi-src-%{version}",
    tag = "Source",
    object_hash | default = "…",
    origin = { tag = "Http", url = "https://github.com/libffi/libffi/releases/download/v%{version}/libffi-%{version}.tar.gz" },
  } in
  let libffi = {
    name = "libffi-%{inputs.source.version}",
    tag = "Autotools",
    deps = { build = [], runtime = [pkgs.glibc_libs] },
    config = { configure_args = ["--disable-static"] },
    inputs = { source = pkgs.libffi_src },
  } in
  { include [libffi_src, libffi] }

request.ncl ties it together: given the paths of one run, the final package set, and an ordered non-empty array of recipe goals, it lowers their shared DAG to a full request. Callers rarely invoke it directly — bin/bobr-build.sh does, reading everything but the run’s own identity from a build profile (bobr.ncl, shaped by profiles/contracts.ncl). A normal profile imports the maintained profiles/bobr-user.ncl preset instead of copying a template.

The profile is itself the Nickel program that constructs the package set and selects its goals:

let bobrpkgs = import "bobr-recipes/bobrpkgs.ncl" in
let pkgs = bobrpkgs [] in
(import "bobr-recipes/profiles/bobr-user.ncl") & {
  include pkgs,
  goals = [pkgs.world],
}

include pkgs adds the existing local pkgs value as a record field. Multiple goals share one lowered DAG and retain their array order. Repeated --target NAME options replace the profile goals for one invocation; each name is looked up in the profile’s final pkgs.

Profile goals are recipe values, not attribute names. They can also contain a recipe imported from outside pkgs, for example:

goals = [(import "./hello.ncl") pkgs],

Here hello.ncl is a function returning a recipe record. Such a recipe needs no overlay to be built, but cannot be selected by --target unless added to pkgs. See Compile a local program.

A build profile can attach ordered local and remote backends to the Realizer. Capabilities are explicit and independent; enabling both on one profile entry is shorthand for two low-level request entries backed by the same source:

secondaries.providers = [
  {
    name = "previous",
    mappings = true,
    content = true,
    local = {
      store = "../bobr-store.previous",
      transfer = "hardlink",
    },
  },
  {
    name = "public",
    content = true,
    remote = {
      master_url = "https://repo.example/bobr/master",
      trusted_keys = ["./repository-key.pem"],
    },
  },
]

mappings allows authoritative BuildKey and ReuseKey answers; content allows acquisition for an already-known hash. Neither implies the other. Local content requires transfer: hardlink requires the relevant store directories to share filesystems, while copy deliberately creates independent inodes and works across filesystems. Local store paths, remote key paths, an optional CA bundle, and secondaries.repository_cache are resolved relative to the top-level profile. An empty cache setting selects <store>/repository-cache. See Request for the normalized wire contract.

An optional output_repository section enables a post-build publication stage in bobr-build.sh. After a successful non-dry-run build, the driver uploads missing content and prepares an unsigned candidate master when the repository state changes; signing and publishing that master are separate administrator actions. This section is not included in the JSON build request. See Repository CLI for preparation and publication.

Local sources

Local files and directories are Source recipes with a declared content hash. Inside bobr-recipes, RecipePath names a path relative to the recipes checkout; request lowering converts it to Path with an absolute host path. For example, a recipe in pkgs/foo/ can refer to a sibling patch:

{
  name = "foo-fix",
  tag = "Source",
  object_hash = std.string.trim (import "./fix.patch.fsobj-hash" as 'Text),
  origin = {
    tag = "RecipePath",
    path = "pkgs/foo/fix.patch",
  },
}

The hash import is relative to the Nickel file; RecipePath.path is relative to the checkout root. A plain Path origin instead requires an absolute host path, including for sources outside the checkout.

The adjacent .fsobj-hash file pins the source content. After editing the patch, refresh it from the recipes checkout:

bin/bobr-update-fsobj-hashes.sh pkgs/foo/fix.patch

bobr-build.sh checks existing hash locks inside the recipes checkout before lowering; it does not update them or check sources outside that checkout. For external sources, update the declared hash yourself after changes. An object already cached under the declared hash can be reused without rereading its origin. See Compile a local program for an external-source example.

Overlays

An overlay is a function fun final => fun prev => patch, where prev is the package set built so far and final is the finished set. Overlays are applied in order, and each one’s fields replace the matching packages — the same idea as Nix overlays. For example, to pin a different shadow version:

[
  fun final => fun prev => {
    shadow_src = prev.shadow_src & {
      version = "4.18.0",
      object_hash = "add4604d3bc410344433122a819ee4154b79dd8316a56298c60417e637c07608",
    },
  },
]

The version and source archive hash must match. For an override to propagate, consumers of a public recipe must refer to it through pkgs (or final in an overlay), not a module-local binding that still names the original recipe.

Important: Recipe names are also the current lowering layer’s deduplication keys. Multiple consumers may share the same recipe value and name, but different reachable recipe definitions must have different names. Lowering does not currently diagnose a conflict: if two different recipes have the same name, it keeps one definition and makes both references point to it.

This is a limitation of the Nickel recipe layer. Recipe names do not participate in Bobr’s BuildKey.

Replacing a public Source such as shadow_src above leaves only one shared definition, so its version-derived name is sufficient. If an overlay instead replaces the Source input of only one consumer while the original Source remains reachable, the replacement needs an explicitly distinct name:

fun _final => fun prev => {
  gzip = prev.gzip & {
    inputs.source | force = prev.gzip_src & {
      name | force = "gzip-src-1.14-local",
      object_hash = "…",
    },
  },
}

Changing only object_hash does not change a name derived from version. Names need to be unique only among different recipes reachable in the same lowered graph.

Recipes are data, not functions

Values in the recipe set are plain data records, not functions. Modules and factories may be functions returning records; their evaluated results are the recipes. An overlay replaces selected fields of the set and can explicitly merge changes into prev.foo with &. A function value cannot be patched that way. Keeping recipes as records makes their versions, configure flags, and inputs accessible to overlays.

Synthetic recipes

The recipe above uses the tag Autotools, which is not a bobr builder — bobr has no such builder. It is a synthetic recipe: a high-level, Nickel-only tag that stands for a common build pattern and is lowered (expanded) into real builder nodes before the request reaches bobr.

Lowering happens in recipe-lib.ncl’s to_request_goals, which walks the recipe graph and looks each node’s tag up in synthetic/registry.ncl. A node whose tag is already a real builder — Tree, TreeMerge, Sandbox, SandboxInstall, Source, and so on — passes through unchanged. A node with a synthetic tag is expanded into real builder nodes: ordinary build families use SandboxInstall with a build rootfs assembled by TreeMerge; staging variants also use TreeMove. Expansion runs on the already-overlaid package set, so overlays always patch the high-level recipe and expansion sees the result; lowering then assigns node ids and emits the JSON nodes map.

HostBundle is different: it is a real bobr builder, used directly from a Nickel recipe rather than expanded by the synthetic registry. Its inputs and typed configuration are documented in HostBundle.

Build and runtime dependencies

A dependency-aware synthetic recipe carries deps = { build = [...], runtime = [...] }:

  • build — packages needed to build this one. The lowering layer assembles the build environment as a TreeMerge of base_filesystem with the runtime closure of a default toolchain plus these build deps, and injects it as the build sandbox’s _rootfs input.
  • runtime — packages this one needs at run time. They form the package’s own runtime closure, which is followed transitively wherever the package appears in another recipe’s build or runtime list.

So a recipe declares what a package needs, and the root filesystem to build it in is assembled automatically from the runtime closures of its build dependencies.

Split outputs

The unsuffixed attribute (pkgs.glibc) is the full install tree. You can also add recipes for split outputs — suffixed attributes carrying a subset of that tree, so a dependent pulls in only what it needs. A split output is just a TreeSubset over an fs-tree, so it is very cheap: it references the same shared fs-files as the full tree, with nothing copied. The built-in helper recipe.split.libs produces a _libs output from the shared libraries, loader, and SONAME symlinks — pkgs.glibc_libs from pkgs.glibc. Attributes are snake_case; the builder name keeps the version in kebab-case (glibc-libs-2.42).

Depend on the narrowest output that has the files you need — deps.runtime on _libs for a library dependency (pkgs.glibc_libs, not pkgs.glibc), the full attribute only when the whole tree is required.

Other suffixes are reserved for the same idea and added by hand as a TreeSubset when a package needs them; today only _libs has a helper (plus one hand-written systemd_dev):

  • _dev — headers, pkg-config/CMake metadata, and dev symlinks
  • _tools — executable utilities
  • _static — static libraries
  • _doc, _locale, _data — documentation, locale data, other architecture-independent runtime data

Avoid _runtime (already the deps.runtime role) and _closure (used by RootfsClosure). _src denotes a public upstream Source recipe, while _gen1 marks a bootstrap stage.

Synthetic builders

A synthetic builder expands to a sandboxed build (see Request) that unpacks the source and runs it through a build pipeline. The build families here lower to the additive SandboxInstall builder: their result is a fs-tree layer of the files the build installs, destined for the target filesystem and merged into a rootfs like any other package. A build whose result is instead a self-contained artifact — a disk image, an archive — rather than files for the target root lowers to the plain Sandbox builder, which captures a @{out} staging directory as a plain object (as recipe.erofs_image does).

Most build families come in two flavors:

  • the dependency-aware ones (Autotools, Makefile, Meson, Cmake, PerlModule, PythonModule, Cargo, plus the custom-steps SandboxInstallDeps and SandboxStageDeps) take a deps record and build their own rootfs from deps.build; scripted families require source, Cargo additionally requires crates, and custom-step recipes declare only the inputs their steps need;
  • the explicit-rootfs variants (AutotoolsStageRootfs, MakefileStageRootfs, MesonRootfs, CargoRootfs, SandboxStageRootfs) take an explicit _rootfs input instead of deriving it from deps.build; they may still declare deps.runtime for consumers of their output.

New, non-bootstrap recipes should use the plain dependency-aware builders; the *Rootfs and *Stage* variants are bootstrap-chain machinery and are rarely the right choice otherwise.

Cmake, PerlModule, and PythonModule have no explicit-rootfs variant.

The common native toolchain referenced by the defaults below is linux_headers, glibc, binutils, gcc, bash, make, coreutils, gawk, sed, grep, tar, gzip, bzip2, xz, patch, findutils, and diffutils.

How a synthetic build runs

Except for the custom-steps Sandbox* builders, the builders share one pipeline, run as sandbox steps against the build rootfs:

  1. prepare — unpack the source and apply patches (below);
  2. any pre_configure (or, for Makefile, pre_build) hook steps;
  3. configure — the tool’s configure step (Makefile has none);
  4. build — the tool’s build step, as build-user, parallel (-j = CPU count);
  5. install — as root into the build’s live overlay root (--prefix=/usr, no DESTDIR); the additive SandboxInstall capture takes the files the install added as an fs-tree layer, which is the recipe’s result. The *StageRootfs and SandboxStageDeps variants instead install into a virgin /stage, re-rooted /stage → / by TreeMove, for bootstrap recipes where writing into / would touch lower-layer files;
  6. any post_install hook steps.

Source and patches. The source input is unpacked into @{build}/source; if it is a single tarball whose contents sit under one wrapping directory, that wrapper is stripped. Any input whose name begins with patch is then applied from the source root with patch -p1, in sorted input-name order — a .patch file directly, and a directory by applying every *.patch it contains. So patches are added as extra inputs:

inputs = {
  source = foo_src,
  patch0 = foo_fix_configure,   # a .patch Source
  patch1 = foo_extra_patches,   # or a Source directory of *.patch files
}

Config. A recipe’s config fields reach the build script through script_config (materialized at @{config}): configure_args, make_args, setup_args, and perl_args become the arguments of the matching command; env entries are loaded by each synthetic build-script phase; source_subdir builds a subdirectory of the source; and in_tree (Autotools) builds in the source tree instead of a separate build directory. Hooks are independent Sandbox steps, so a hook that needs environment variables must declare its own env.

Hooks. pre_configure / pre_build / post_install are extra Sandbox steps injected into the pipeline — a single step or an array. Each is a normal step (name, run_as, argv, optional env, and cwd, which defaults to the source directory for pre-hooks and the install root for post_install: / for ordinary installs or /stage for staging variants), so a recipe can run arbitrary commands before configuring or after installing without leaving the synthetic builder.

Autotools

  • Steps: ./configure --prefix=/usr <configure_args> → make -j <make_args> → make <make_args> install.
  • Config (all optional): configure_args, make_args, env, in_tree, source_subdir, pre_configure, post_install.
  • Default build tools: the common native toolchain plus autoconf, m4, and perl.

Makefile

  • Steps: no configure; make -j <make_args> → make <make_args> install (skipped when skip_install is set).
  • Config (all optional): make_args, env, source_subdir, pre_build, post_install, skip_install.
  • Default build tools: the common native toolchain.

Meson

  • Steps: meson setup <build-dir> <source> --prefix=/usr <setup_args> → meson compile -j → meson install --destdir /.
  • Config (all optional): setup_args, env, source_subdir, pre_configure, post_install.
  • Default build tools: the common native toolchain plus pkgconf, python, meson, ninja, python_setuptools, and python_packaging.

Cmake

  • Steps: in-source cmake -S . -B . (Unix Makefiles generator; -DCMAKE_INSTALL_PREFIX=/usr and -DCMAKE_BUILD_TYPE=Release baked in) with <cmake_args> → make -j → make install.
  • Config (all optional): cmake_args, make_args, env, source_subdir, pre_configure, post_install.
  • Default build tools: the common native toolchain plus cmake.

PerlModule

  • Steps: perl Makefile.PL <perl_args> → make -j <make_args> → make <make_args> install.
  • Config (all optional): perl_args, make_args, env, source_subdir, pre_configure, post_install.
  • Default build tools: the common native toolchain plus perl.

PythonModule

  • Steps: build a wheel with pip wheel --no-build-isolation --no-deps, then pip install --prefix /usr (no --root) into the overlay root.
  • Config (all optional): env, source_subdir, pre_configure, post_install.
  • Default build tools: the common native toolchain plus python.

Cargo

  • Steps: vendor the crate dependencies offline (below), then run cargo install --path <source> --offline --locked to build and install the selected crate [[bin]] targets into the overlay root.
  • Inputs: required source and crates. crates is normally a public Bundle recipe generated from the upstream Cargo.lock with cargo.vendor_inputs from cargo.ncl.
  • Config (all optional): features, bins, no_default_features, env, source_subdir, post_install.
  • Default build tools: the common native toolchain plus rust_bin.

One typical public dependency bundle is:

let cargo = import "../../cargo.ncl" in
let foo_crates = {
  version | default = "1.2.3",
  cargo_lock | default = import "Cargo.lock" as 'Toml,
  name = "foo-%{version}-crates",
  tag = "Bundle",
  config = {},
  inputs = cargo.vendor_inputs cargo_lock,
} in
# ... inputs = { source = pkgs.foo_src, crates = pkgs.foo_crates }

Vendoring. The sandbox has no network, so a Rust build cannot pull crates from crates.io; its dependencies are provided by the explicit crates input. cargo.vendor_inputs turns a parsed lockfile (import "Cargo.lock" as 'Toml) into one .crate HTTP Source per crates.io entry. Each Source’s object_hash is exactly the checksum recorded in Cargo.lock: the fsobj-hash of a non-executable file equals its SHA-256, and crates.io serves each version as an immutable .crate, so the lockfile content-addresses and verifies every vendored crates.io dependency. Git, path, and alternative-registry dependencies are not fetched by this helper. A built-in step unpacks the bundle into an offline Cargo vendor/ tree before the build.

The source archive and its crates bundle can be separate, overridable recipes and must describe the same release. Recipes exposing such a pair should check their declared versions. Cargo then uses the Cargo.lock shipped by the unpacked source with --locked --offline. A missing dependency, incompatible lockfile, or checksum mismatch therefore fails the build instead of changing the lockfile or accessing the network. Custom Meson or Autotools recipes that invoke Cargo should run cargo.verify_lock_step after preparing the same vendor directory.

CargoRootfs has the same explicit source and crates inputs as Cargo and additionally requires _rootfs.

SandboxInstallDeps, SandboxStageDeps, SandboxStageRootfs

Run your own Sandbox step plan instead of the pipeline above — the same config as the low-level SandboxInstall builder (steps, script_config; see Request). Source unpacking and patching are not automatic here; do them in your own steps. All three capture their result additively:

  • SandboxInstallDeps — build rootfs from deps; the steps install into the live overlay root (/);
  • SandboxStageDeps — build rootfs from deps; the steps must install into a virgin /stage, re-rooted /stage → / by TreeMove;
  • SandboxStageRootfs — the /stage model of SandboxStageDeps, but with an explicit _rootfs input rather than one derived from deps.build.

For a plain-object result — a @{out} staging directory rather than an fs-tree, e.g. a disk image — use the low-level Sandbox builder with an explicit _rootfs directly (as recipe.erofs_image does).

  • Default build tools (the Deps variants): bash, tar, gzip, bzip2, xz, and patch.

RootfsClosure

Not a build: assembles a root filesystem as a TreeMerge of base_filesystem with the runtime closure of its inputs — the standard way to turn a set of packages into a usable rootfs.

  • Inputs: one or more packages (the roots of the closure).
  • Config: none.

Filesystem images

Filesystem images are produced by Sandbox recipes that run a packing tool over a materialized _tree input, in a rootfs assembled from the tool’s runtime closure. The result is an ordinary object containing the image file.

EROFS

To pack a filesystem tree into a reproducible EROFS image, use the recipe.erofs_image helper — it lowers to a low-level Sandbox (with an explicit _rootfs: a RootfsClosure of pkgs.erofs_utils and pkgs.bash) that runs mkfs.erofs over the materialized tree:

let qemu_image = recipe.erofs_image pkgs { name = "qemu-image", tree = pkgs.qemu_rootfs }

The reproducibility flags (--sort=path, a fixed -T) and the constant label (bobr-rootfs) live in the os/mk-erofs.sh build script, which also derives the image UUID (a version-8 UUID) from BOBR_BUILD_SEED — the per-build seed the sandbox exports (see Request). Same inputs give the same seed, so the image stays reproducible while distinct rootfs contents get distinct, non-null UUIDs. Being a plain-object Sandbox, the result is a plain-object directory holding a single erofs-rootfs.erofs file, since the image blob has no per-file ownership of its own.

initramfs

The initrd is packed the same way, in linux/initrd.ncl: a Sandbox over the merged initrd tree that runs linux/mk-initramfs.sh and writes a single initramfs.img, so the boot artifact is <object>/initramfs.img.

The archive is written by the kernel’s own gen_init_cpio (built from the kernel source as pkgs.gen_init_cpio), driven by a spec file the script generates from the tree — the two-stage scheme the kernel uses for CONFIG_INITRAMFS_SOURCE. That split is what makes the image reproducible: the spec carries only path, type, permissions, and ownership, and gen_init_cpio synthesizes the rest (inode numbers from a constant, fixed link counts, one SOURCE_DATE_EPOCH timestamp). Running cpio(1) over the tree instead would copy the on-disk inode numbers, link counts, and mtimes — and a materialized fs-tree hardlinks its files from the store’s shared fs-files, so those vary with what else the store holds.