builder#
OCI-oriented build engine: it runs the steps of a Dockerfile-like build spec, snapshots the filesystem between steps, and content-addresses each step result so an unchanged step is reused from cache instead of re-run.
builder is assembled from existing blacksun libraries rather than reinventing
them. The build-spec format is adapted from OBuilder's obuilder-spec,
serialized through nox-dockerfile instead of S-expressions and exposed
as the builder.spec sublibrary (module Builder_spec). Image layers and OCI
manifests are produced by ocaml-oci; macOS sandbox profiles by
ocaml-seatbelt. The orchestration loop, the copy/overlay
snapshot store, and the step cache are re-cut from OBuilder onto Eio, with a
small Unix fork/exec helper for host RUN process control.
The runner is runtime-parameterized: ocaml-builder owns plan execution, cache
keys, snapshots, COPY, and OCI layout export; the caller supplies the RUN
execution boundary as a Builder.Runtime.t. In production that boundary is a
VM guest, which is where the isolation comes from (see Security
model); Builder.Macos is the lightweight host runtime used
for trusted local builds and the unit-test suite. The same engine drives both,
so most behaviour is exercised on the host without a VM. On Linux, the runner
uses an overlayfs snapshot store with OBuilder-style parent links between
committed upper layers.
Security model#
The Linux runc runtime takes an explicit deployment that chooses the isolation
model, mirroring the two stances of the reference build systems it draws from.
`Single_tenant_vm (OBuilder-style) is designed to run inside a
single-tenant virtual machine: one build, in its own VM, with no co-tenant to
protect. The VM is the isolation boundary against a hostile build step. Inside
it, builder runs as root, owns the snapshot filesystem directly, and treats
that filesystem as disposable build state rather than a host to defend. That is
what lets COPY write straight into the snapshot, RUN execute against it, and
secrets be materialized as files: nothing escapes the VM, so a hostile step is
contained at the VM boundary, not at each syscall. Steps run with runc's default
capabilities and no user namespace.
`Multi_tenant (BuildKit-style) hardens each step so mutually
distrusting tenants can share one host without a per-build VM. Build it with
Builder.Runc.multi_tenant (). Each RUN step then executes in a user namespace
that maps container root to an unprivileged host uid (default: container ids
[0, 65536) to host ids from 100000, the conventional first /etc/subuid
range), with a reduced capability set (the containerd/Docker default minus
CAP_MKNOD and CAP_AUDIT_WRITE), noNewPrivileges, and cgroup pids limits.
Container root therefore owns nothing on the host outside the build, and the
namespace confines the remaining capabilities to the tenant's own ids. This is a
privileged builder: presenting the snapshot to the namespace needs the host
privilege to set the id mapping (see below), so multi-tenant isolation is not
rootless. Override the mappings, resources, or capabilities for a host with a
different subordinate-id allocation.
For the mapped run to succeed, the snapshot rootfs must appear owned within the
mapped host id range (so container root can read and write it). The runtime
prefers an idmapped mount (Builder.Idmap): it clones the snapshot mount and
attaches a user namespace that maps the on-disk ids into the container range at
the VFS layer, so ownership shifts with no tree walk and no on-disk change, and
the mapping is undone by a plain unmount. The runc rootfs (root.path) points
at this mountpoint while the snapshot itself stays builder-owned for secret
placeholders and the committed result. The OCI runtime spec has no field to
idmap the rootfs, so the builder creates the mount itself, the way containerd's
overlay snapshotter does.
Idmapped mounts need mount_setattr (Linux 5.12+) and a filesystem that
supports them (overlayfs 5.19, ext4/xfs/btrfs 5.12). When the kernel or the
snapshot's filesystem cannot idmap, the runtime falls back to chowning the
snapshot into the range before each step and restoring the builder's ownership
afterwards (Builder.Runc.snapshot_owner is the policy it applies); that
fallback is O(files) per step and flattens per-file ownership onto the mapped
root, whereas the idmapped mount preserves it. Either way the operator runs the
builder with a matching /etc/subuid//etc/subgid allocation and the privilege
to set the mapping. As for the single-tenant path, an actual runc run should
be smoke-tested in a privileged worker.
The macOS host runtime (Builder.Macos) is the trusted, no-VM case, for
building specs you control on a developer machine. It runs each RUN step
through a generated default-deny sandbox-exec profile: the build root is
read/write; /usr, /bin, /sbin, /System, /Library, /private/etc,
/private/var/select, and a small set of device nodes are readable;
/dev/null is writable; and RUN --network=none adds an explicit network deny
rule. This is a guardrail against a build step accidentally mutating the host,
not isolation against a hostile one: sandbox-exec is deprecated (its man page
steers developers to the App Sandbox instead), and its profile language is
undocumented and subject to change. Without the VM boundary, build only specs
you trust.
Secrets attached to a RUN step are opt-in: the engine calls the configured
secret resolver by id, materializes the value as a mode-0600 file at the
requested secret target, rejects it if that target already exists, and
removes the file (following any hardlinks to it by inode) before committing the
snapshot. A removal that cannot complete fails the step rather than being
swallowed: a secret left in the snapshot is one that would ship in the committed
layer. Raw secret values are not written into cache keys, but their digests are
included so rotating a secret invalidates affected RUN steps.
Install#
This package is not yet in the public opam-repository. Add the overlay
repository, then install it:
$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam update
$ opam install builder
Usage#
A spec is a base image plus the ordered operations applied on top of it. Build
one with Spec.stage, Spec.run, Spec.copy and friends, and render it to a
Dockerfile:
let spec =
Builder.Spec.stage ~from:"alpine"
[
Builder.Spec.run "apk add --no-cache git";
Builder.Spec.copy [ "." ] ~dst:"/src";
]
Builder.Spec.pp spec renders:
FROM alpine
RUN apk add --no-cache git
COPY [ ".", "/src" ]
To execute a spec, lower it to a Plan, provide a runtime, and run it through
Builder.Runner. Each step runs on a mutable copy of base, and the
successful result is committed as an immutable content-addressed snapshot under
state_dir; run returns the final step's id and snapshot directory.
open Builder
let build ~state_dir ~base spec =
let runtime = Macos.runtime () in
let runner = Runner.v ~store:`Auto ~runtime ~state_dir () in
Runner.run runner ~base (Plan.of_spec spec)
Macos.runtime defaults to the sandbox-exec profile described under
Security. Pass ~secret to resolve RUN secrets.
~store:\Autoprobes overlayfs understate_dirand otherwise uses the portable copy store;~store:`Overlayfails if overlayfs cannot be mounted.Runner.prune`
reclaims space by dropping cached snapshots.
On Linux, use Builder.Runc.runtime ~deployment:`Single_tenant_vm inside a
prepared VM or worker, or ~deployment:(Builder.Runc.multi_tenant ()) to share
a host between distrusting tenants (see Security model). The
explicit deployment argument is intentional: it picks the isolation model rather
than guessing. Either way the runtime writes an OCI config.json with
ocaml-runc (its seccomp profile resolved for the build's target architecture,
passed as ~arch, rather than by probing the host with a uname that fails
inside a minimal VM), runs runc run in the foreground, sends stdout/stderr to the
per-step build log, maps RUN --network=none to a private network namespace, and
materializes RUN secrets as read-only bind mounts outside the committed snapshot;
the multi-tenant deployment additionally wraps each step in a user namespace with
reduced capabilities and cgroup limits, and chowns each secret's bind source to
the mapped container-root id so the namespaced step can read it.
Multi-artifact builds#
A Plan builds one image. builder.graph composes many artifacts - images, an
init, disks, each keyed on a prior artifact's built output - into one
content-addressed graph, so a change to any input rebuilds exactly what depends
on it and nothing else. The same composition drives both the build and the
--plan view, so the two cannot drift.
A composition is a typed value (the authoring surface is OCaml; there is a JSON
Composition.codec for tooling). Each artifact names its build rule, the
source leaves it is keyed on, and its dependency edges - Key on another
artifact's derivation key, or Output on another artifact's realised output
digest:
module Composition = Builder_graph.Composition
let composition =
let src label digest = { Composition.label; digest } in
let artifacts =
[
{
Composition.name = "base";
rule = "image";
version = 0;
sources = [ src "dockerfile" "sha256:aaa"; src "context" "sha256:bbb" ];
deps = [];
};
{
Composition.name = "app";
rule = "image";
version = 0;
sources = [ src "dockerfile" "sha256:ccc"; src "context" "sha256:ddd" ];
deps = [ Composition.Key "base" ];
};
{
Composition.name = "disk";
rule = "disk";
version = 0;
sources = [ src "size" "8GiB" ];
deps = [ Composition.Output "app" ];
};
]
in
match Composition.v artifacts with
| Ok c -> c
| Error e -> Fmt.failwith "%a" Composition.pp_error e
Composition.v validates (unique names, every dependency present, acyclic) and
returns the artifacts in topological order; Composition.artifacts composition
here yields base, app, disk. name is the node's identity in the graph -
what dependency edges point at, never hashed - while rule (with version) is
the build recipe folded into the content-addressed key: renaming an artifact is
a cache hit, changing its rule or bumping its version rebuilds it.
Builder_graph.Driver.Make lifts the per-node memoise-and-build primitive to a
whole composition, parameterised over a content store S (the production store
is OCI-backed; an in-memory one keyed on the derivation key serves to
illustrate):
module Derivation = Builder_graph.Derivation
module Store = struct
type handle = (string, string) Hashtbl.t
type output = string
let find h k = Hashtbl.find_opt h (Derivation.string_of_key k)
let save h k o = Hashtbl.replace h (Derivation.string_of_key k) o
let digest _ output = output
let cached_info h k =
Option.map
(fun o -> (Derivation.string_of_key k, String.length o))
(find h k)
(* an in-memory store has no layer grain to report *)
let image_layers _ _ = []
end
module Driver = Builder_graph.Driver.Make (Store)
(* Realise the whole graph in one call: topological order, dependencies first,
threading each realised output into its dependents' build. The injected
action dispatches on the rule and receives its dependencies' outputs. *)
let outputs =
let handle = Hashtbl.create 16 in
Driver.realise (Driver.v handle composition) ~build:(fun ~artifact ~deps:_ ->
Printf.sprintf "%s-output" artifact.Composition.name)
(* The pre-build view, derived from the same composition (drawn as a tree or
emitted as JSON for --plan). An Output dependent shows Deferred until its
dependency is cached. *)
let plan : Builder_graph.Build_graph.t =
Driver.plan (Driver.v (Hashtbl.create 16) composition)
The driver owns the walk and the caching, and emits ocaml-probe spans and
events for each artifact (Builder_graph.Event).
buildr CLI#
buildr builds an OCI image from a Dockerfile. It is Linux-only: every
RUN step executes under runc.
buildr -t myapp:latest .
oci checkout myapp:latest # the built image is in the shared OCI cache
It parses the Dockerfile, fetches and unpacks the FROM image with
ocaml-oci, runs each step through the runc runtime, and stores the result in
the local OCI cache under -t -- the same cache the oci CLI reads, so
oci checkout/oci show find the image immediately.
-t NAME[:TAG]is the image's name in the cache; that, or-o DIR, is required (a build with neither output is rejected rather than discarded).-o DIRalso writes a standalone OCI image layout (readable asoci:DIR).--state-dir DIRpoints at the build state directory for snapshots, temporary checkouts and per-step working state.--oci-cache DIRpoints at the shared OCI cache used for fetched base images and tagged outputs (default: the cache theociCLI uses).- Each
RUNis hardened by default (user namespace, reduced capabilities, cgroup limits), the BuildKit-style stance. Pass--single-tenantto run with runc defaults as root, for whenbuildritself runs inside a disposable VM/worker (see Security model). --print-planlowers the Dockerfile and prints the execution plan without fetching or running anything.--platform os/archselects the build target (default: the host).-uand theIMAGE_TOKENenvironment variable authenticate base-image pulls.
The supported Dockerfile subset is Builder.Spec.of_dockerfile's: FROM (with
an optional AS alias, and multi-stage builds where a later stage bases on or
COPY --froms an earlier one), WORKDIR, ENV, SHELL, USER (numeric, or a
named user resolved at execution time), shell-form RUN (including BuildKit
cache mounts and --network hints), COPY from the build context or an earlier
stage (--chown/--chmod are dropped), LABEL, and comments.
A RUN step also inherits the base image config's own state -- the
environment it ships (ENV/PATH), its USER and its WORKDIR -- layered
under the Dockerfile's own directives. Once each FROM image is fetched,
buildr reads its config back from the cache with Oci.config and feeds it to
Builder.Plan.of_spec's ?base_config resolver (via
Builder.Plan.base_config_of_oci), so a RUN sees the same PATH and working
directory the image was built to run with. A docker load archive output is a
follow-up.
Linux test image#
From the monorepo root, build the Linux test image with:
docker build -f ocaml-builder/Dockerfile.test .
This installs the local mono copies of ocaml-runc, ocaml-oci,
nox-dockerfile, and the other builder dependencies, then runs
dune build @ocaml-builder/all @ocaml-builder/runtest. The default test suite
checks runc config generation without requiring a privileged container; an
actual runc run smoke test should be run inside a privileged Linux VM/worker.
API#
The umbrella module Builder re-exports:
Builder.Spec— the build-spec format and Dockerfile renderingBuilder.Plan— lowering a spec to an executable planBuilder.Runtime— the callback interface forRUNexecutionBuilder.Runner— the snapshot/cache/OCI runnerBuilder.Macos— the macOS host runtime and sandbox profilesBuilder.Runc— the Linux runc runtime for VM/worker executionBuilder.Export— theocaml-ociimage surface (layers, manifests, push)
The builder.graph library is separate (module Builder_graph):
Builder_graph.Derivation— a content-addressed build node + its memoise-and-build primitiveBuilder_graph.Composition— the typed multi-artifact spec, its JSON codec, validation and loweringBuilder_graph.Driver— realising a whole composition and rendering its planBuilder_graph.Build_graph— the pre-build plan view (--plan/JSON)Builder_graph.Event—ocaml-probeinstrumentation for the graph walk
See the .mli files for the full interface.
Credits and license#
builder is licensed under the Apache License 2.0, matching the imported
OBuilder code. See LICENSE and NOTICE. OBuilder is by
Thomas Leonard and the OCurrent team.