OCI-oriented build engine
0

Configure Feed

Select the types of activity you want to include in your feed.

OCaml 95.3%
C 2.4%
Dune 0.4%
Perl 0.1%
Raku 0.1%
Other 1.8%
193 1 0

Clone this repository

https://git.vm.fail/gazagnaire.org/ocaml-builder https://git.vm.fail/did:plc:siicccbl3o75j7gjrejglqyk
ssh://git@git.recoil.org:2222/gazagnaire.org/ocaml-builder ssh://git@git.recoil.org:2222/did:plc:siicccbl3o75j7gjrejglqyk

For self-hosted knots, clone URLs may differ based on your setup.


README.md

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 DIR also writes a standalone OCI image layout (readable as oci:DIR).
  • --state-dir DIR points at the build state directory for snapshots, temporary checkouts and per-step working state.
  • --oci-cache DIR points at the shared OCI cache used for fetched base images and tagged outputs (default: the cache the oci CLI uses).
  • Each RUN is hardened by default (user namespace, reduced capabilities, cgroup limits), the BuildKit-style stance. Pass --single-tenant to run with runc defaults as root, for when buildr itself runs inside a disposable VM/worker (see Security model).
  • --print-plan lowers the Dockerfile and prints the execution plan without fetching or running anything.
  • --platform os/arch selects the build target (default: the host).
  • -u and the IMAGE_TOKEN environment 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 rendering
  • Builder.Plan — lowering a spec to an executable plan
  • Builder.Runtime — the callback interface for RUN execution
  • Builder.Runner — the snapshot/cache/OCI runner
  • Builder.Macos — the macOS host runtime and sandbox profiles
  • Builder.Runc — the Linux runc runtime for VM/worker execution
  • Builder.Export — the ocaml-oci image 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 primitive
  • Builder_graph.Composition — the typed multi-artifact spec, its JSON codec, validation and lowering
  • Builder_graph.Driver — realising a whole composition and rendering its plan
  • Builder_graph.Build_graph — the pre-build plan view (--plan/JSON)
  • Builder_graph.Eventocaml-probe instrumentation 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.