holm

Custom images

By default, a box uses an image that the crate builds from its own Dockerfile. This guide uses a different image: one built from your own Dockerfile, or one that is already in a registry.

Custom images are a feature of the Rust library. The CLI, REST API, and MCP tools use the built-in image, with the applications and packages that a spec adds.

Select an approach

You want to Do this
Add packages or a catalog application Use packages([…]) or a spec. You do not need a custom image. See Configure a box.
Add files, configuration, or tools that apt does not have Build an image FROM the built-in image. See Extend the built-in image.
Start boxes with no build step Build the image in CI, push it to a registry, and use image(…). See Build images in CI.
Change the display stack, the scripts, or the ports Define a profile. See Define a profile.

Two builder methods

Method Source The crate builds it Packages
image_dir(path) A directory with a Dockerfile Yes, when the directory changes Accepted
image(tag) An image that exists, local or in a registry No Refused
let computer = Computer::builder()
    .image_dir("images/my-desktop")
    .launch()
    .await?;

let computer = Computer::builder()
    .image("registry.example.com/desktop:1")
    .launch()
    .await?;

For image_dir, the image tag contains a hash of all files in the directory. A change to any file makes a new image. Packages from packages([…]) go to the build as the EXTRA_PACKAGES build argument.

With image, the crate does not build anything, so it cannot add packages. packages([…]) with image(…) fails with Unsupported.

The image contract

The desktop driver runs fixed commands and connects to fixed ports in the image. An image must provide them, or the box does not start or some tools do not work.

The contract of the built-in X11 profile (holm-desktop):

Item Value
Boot command holm-desktop --once
Screen command holm-screen
Browser command holm-browser
Screen size From HOLM_SCREEN_WIDTH and HOLM_SCREEN_HEIGHT. Default 1280x800.
Watch viewer, screen N Port 6080 + 2N
Control viewer, screen N Port 6081 + 2N
Chrome DevTools Port 9222, with a bridge on 9223
Screens At most 8

The Wayland profile uses the name holm-wayland.

The easy way to meet the contract is to build FROM the built-in image, which already has all of it.

Declare the contract

Put this label in the Dockerfile:

LABEL holm.profile="holm-desktop"

Before it starts a box, the crate reads the label. If the image declares a different profile from the one that drives the box, the launch fails at once with the two names. With no label, the crate does not check, and a mismatch shows only as a failure later.

Extend the built-in image

examples/images/acme/Dockerfile adds two packages and a file to the built-in image:

ARG BASE=holm-desktop:unset
FROM ${BASE}

RUN apt-get update \
    && apt-get install -y --no-install-recommends htop jq \
    && rm -rf /var/lib/apt/lists/*

RUN mkdir -p /opt/acme \
    && printf 'built into the image, not written after launch\n' > /opt/acme/README

LABEL holm.profile="holm-desktop"

The built-in image tag changes when the crate's image source changes, so the example passes it in as BASE. Get the current tag in Rust:

use holm::bundle::{DESKTOP, Extras};

let base_tag = DESKTOP.tag_with(&Extras::none());

The built-in image must exist locally before docker build can use it. Launch one box first, or build it as in Build images in CI.

Then build and use the new image:

docker build --build-arg BASE="$BASE_TAG" --tag acme-desktop:1 examples/images/acme
let computer = Computer::builder()
    .image("acme-desktop:1")
    .launch()
    .await?;

Run the full example:

cargo run --example custom_image

Build images in CI

The first box with a new image waits for a build of several minutes. To avoid this, build the image in CI and push it to a registry.

The built-in Dockerfile and its scripts are in crates/holm-core/images/desktop. The directory is a complete Docker context:

docker build \
  --build-arg EXTRA_PACKAGES="jq ripgrep" \
  --tag registry.example.com/desktop:1 \
  crates/holm-core/images/desktop
docker push registry.example.com/desktop:1

EXTRA_PACKAGES is a list of apt packages, separated by spaces.

Then launch from the registry. The runtime pulls the image when the host does not have it:

let computer = Computer::builder()
    .image("registry.example.com/desktop:1")
    .launch()
    .await?;

Build the image again for each release of the crate that changes crates/holm-core/images/, so the scripts in the image match the driver.

For a server, holm image build builds the image that a runtime needs on the server's host. See Deploy holmd.

Other contexts in the repository

crates/holm-core/images has other contexts that implement the same holm-desktop contract. Tests keep their scripts the same as the built-in ones.

Directory Base
desktop Debian bookworm. The built-in image.
ubuntu Ubuntu 24.04, with Chromium from the Xtradeb PPA
tiny Debian bookworm, with documentation, locales, and other large files left out
wayland The Wayland image. Contract holm-wayland.

Use one with image_dir:

let computer = Computer::builder()
    .image_dir("crates/holm-core/images/ubuntu")
    .launch()
    .await?;

Define a profile

A profile tells the driver how to use an image: its contract name, its image, its ports, its commands, its screen size, and what it supports. Use ProfileBuilder to change part of a built-in profile and keep the rest:

use holm::{CommandScreen, CommandWallpaperRuntime, ProfileBuilder, X11Profile};

let profile = ProfileBuilder::new(X11Profile)
    .name("my-desktop")
    .image_dir("images/mine")
    .screen_commands(CommandScreen::new("my-screen"))
    .wallpaper_runtime(CommandWallpaperRuntime::new("my-wallpaper"))
    .build();

let computer = Computer::builder()
    .profile(Arc::new(profile))
    .launch()
    .await?;
Method Changes
name(…) The contract name. It must match the image's holm.profile label.
image(ImageSource), image_dir(path) The image.
boot_command(…) The command that starts the desktop.
screen_commands(…) The command that starts and stops a screen.
browser_runtime(…), wallpaper_runtime(…), screen_runtime(…) How the driver opens the browser, sets the wallpaper, and runs screen tasks.
ports(PortLayout) The viewer, VNC, and DevTools ports.
geometry(GeometrySpec) The screen size variables and the default size.
support(DesktopSupport) What the profile claims to support.
driver(…) The desktop driver.

The base can be X11Profile or WaylandProfile. A new display server needs its own implementations of the Desktop and DesktopFactory traits.

Audit a desktop

A profile claims what it supports in its DesktopSupport. An audit tests those claims against a running box: the screen, the pointer, the DevTools connection, the clipboard, the viewer, and the takeover, where the profile claims them.

let audit = holm::audit(&computer).await;
println!("{audit}");
assert!(audit.ok());

audit.met lists the claims that work. audit.unmet lists the claims that do not work, with the reason. audit.skipped lists the checks that did not run, with the reason. The audit leaves the screen as it was, except for the pointer and the clipboard.

To fail on a gap, with a time limit:

let audit = holm::audit::audit_strictly(&computer, Duration::from_secs(120)).await?;

audit_strictly returns Denied with the report when a claim does not work, and Timeout when the audit does not finish in time.

Run an audit on a new image before you use it, and on one box after each deploy.