Day10: opam package testing tool
Mark Elvers
5 min read

Categories

  • ocaml,day10

Tags

  • tunbury.org

ocurrent/obuilder is the workhorse of OCaml CI testing, but the current deployment causes packages to be built repeatedly because the opam switch is assembled from scratch for each package, leading to common dependencies being frequently recompiled. day10 uses an alternative model whereby switches are assembled from their component packages.

Assuming a package A depends upon B and C, while package B depends upon D and E, which is represented by the graph below. day10 would build package D in isolation, capturing the files written to the opam switch and the operating system dependencies. This would be repeated for all the leaf packages E and C. Then, the sets of changed files for both D and E are merged into a new switch, and package B is installed in that switch using the same capturing methodology. For package A, the file sets for D, E, C and B, in order, are merged, and package A is installed.

    A
   / \
  B   C
 / \
D   E

On its own, this is slower than using opam to create the same switch, as opam processes these steps in parallel. However, to create a new switch for package F, day10 can reuse the file sets for B, D, and E without recreating them.

    F
   / \
  B   G
 / \
D   E

In general, each package is installed exactly once and reused on other switches. However, in some cases, packages enable different functionality depending on which other packages are installed. logs is a good example, with optional libraries such as fmt, cmdliner, lwt, etc. In this case, the package would be installed once for each dependency combination.

The original concept of merging files and recreating the switch came from Jon’s Opam hijinx tool. jonludlam/opamh. This functionality is distilled in day10 in this function, which builds the switch state from the directory listing of the installed packages.

let dump_state packages_dir state_file =
  let content = Sys.readdir packages_dir |> Array.to_list in
  let packages = List.filter_map (fun x -> OpamPackage.of_string_opt x) content in
  let sel_compiler = List.filter (fun x -> List.mem (OpamPackage.name x) compiler_packages) packages in
  let new_state =
    let s = OpamPackage.Set.of_list packages in
    { OpamTypes.sel_installed = s; sel_roots = s; sel_pinned = OpamPackage.Set.empty; sel_compiler = OpamPackage.Set.of_list sel_compiler }
  in
  OpamFilename.write (OpamFilename.raw state_file) (OpamFile.SwitchSelections.write_to_string new_state)

opam could be used to install the package in the “recreated” switch, but opam does unnecessary checks, such as finding and checking whether the necessary dependencies are installed. This led to the tool mtelvers/opam-build, which assumes everything is already in place and calls the opam library to install the package without any checks!

The dependency graph includes the compiler, so package ‘D’ might be OCaml 5.4.0, and package ‘E’ might be an OS dependency like ‘conf-curl’, and the captured layer would include libcurl.so. The underlying OS distribution and version are also captured, so logs on Debian is assumed to be different to logs on Fedora.

On Linux, day10 uses overlayfs with runc. Overlayfs has the concept of a read-only lower directory and a writable upper directory. While these can be stacked, the depth is limited. Therefore, day10 assembles the lower directory by creating a file system tree of hard links to the originally captured files, and an initially empty upper directory is used to capture the files written to it. On FreeBSD, unionfs is used similarly using jails. On Windows, containerd is used, but the filesystem isn’t isolated as the hard-linked directory is writable. This hasn’t presented a probably in day-to-day use.

A typical command-line for day10 would specify an initially empty layer-cache directory, your clone of the opam repository, an output format of Markdown or JSON, and the package to be installed using opam’s naming syntax:

day10 health-check --cache-dir /var/cache/day10 --opam-repository /home/mtelvers/opam-repository --md log.md 0install.2.18

day10 will attempt to detect your system and build an appropriate container, but you can override this --os and then in detail with --os-distribution, --os-family and --os-version

/var/cache/day10/
└── debian-13-x86_64                         # os specific tag
    ├── 0149f9a8b66c1568d0b962417e827d9f     # layer hash
    │   ├── build.log                        # build log
    │   ├── config.json                      # runc configuration file
    │   ├── fs                               # root file system
    │   ├── hosts                            # runc container hosts file
    │   ├── layer.json                       # layer dependencies and their hashes
    │   └── opam-repository                  # copy of the opam files used to create the switch
    │       ├── packages                     #   laid out in opam repository layout
    │       └── repo

day10 uses lock files on each layer to allow multiple instances to be invoked at the same time to build different packages. day10 also accepts a list of packages using @packages.json rather than a specific package name, which can be used along with --fork to internally create multiple instances.

It’s difficult to know how many processes to fork at once, particularly when packages may be partially or entirely cached and only require the SAT solver to process the request (typically takes less than one second). Therefore, it is often useful to separate the solving step from the building step and run them with different levels of parallelism. For example, day10 health-check ... --json /path/to/output --fork $(nproc) --dry-run @packages.json which will solve every package and output a JSON file containing a status field. This allows a second pass to be made with a more conservative --fork N parameter, as actual building of packages will be performed, only those with a status of “solution” actually need to be submitted.

Status Meaning
success The package built successfully, and the build log and dependency graph are included in the output.
failure The package itself fails to build. The build log is included in the output.
no_solution The dependencies of the package cannot be satisfied with the current constraints: compiler version, OS, etc
dependency_failed A dependency failed to build; the log of that failure is included.
solution A solution is available, but a dependency and/or the package itself has not been built. This is only generated with --dry-run.

There is a list command to extract a list of packages from an opam repository. This accepts --all-version but defaults to the latest version.

day10 list --opam-repository ~/opam-repository --os-distribution debian --os-family debian --os-version 13 --json packages.json

Run the build with --fork 20.

day10 health-check --cache-dir ~/cache/ --opam-repository ~/opam-repository --os-distribution debian --os-family debian --os-version 13 --json /tmp/foo --fork 20 @packages.json

On my E5-2640 machine (2 x 10C 20T) with an SATA SSD, building the latest version of every package for a single compiler version and OS variant takes a little over an hour.

The project code is available at mtelvers/day10.