The guide · explanation
Templates and the store
A template is a sealed, read-only disk image that VMs boot linked clones of. It is vmlab's answer to the Vagrant box: build it once from an installer ISO or an existing disk, store it under a name and version, and clone it into as many labs as you like. This chapter explains what a template holds, how the store is laid out, how a build runs, and the two ways a template leaves the machine it was built on. The fields of a template block are in Lab file: template and source, and every verb in vmlab template.
What a template is
A store entry is two files: disk.qcow2, the sealed image, and template.wcl, its metadata. The metadata records the hardware the template was built with (profile, CPUs, memory, disk size, firmware, TPM, secure boot, display, CPU model, accelerator), where it came from, the version of vmlab-agent baked into it, the wscript surface version its embedded scripts were written against, an optional first-boot script, and the OCI repository it publishes to. The hardware fields form the template layer of the resolution chain described in The lab file: a VM that does not set memory inherits it from here.
Templates are keyed by arch, name and version, and a reference is written <arch>/<name>[@<version>][#<variant>]. The arch is mandatory and never inferred from the host, because it selects which QEMU system emulator boots the image. Omitting the version means the highest in the store. vmlab validate rejects an archless reference. The #<variant> suffix is described in the next section.
Variants
A template version can hold more than one sealed disk. It has one base and zero or more variants, each a named disk state built from the base. The case they exist for is Windows. The base is installed and updated but not generalized. Variant standard runs only the generalize. Variant win3d installs a GPU driver package first, then generalizes, with secure boot off. The hours of install and update are stored and built once, and each variant costs only its own changes. A template with no variants is base-only and works as every template always has.
| Reference | Template with variants | Template without variants |
|---|---|---|
| no # | The default variant. | The base. |
| #<name> | That variant. An unknown name is an error that lists the variants. | An error: the template has no variants. |
| #base | Refused for a lab machine, because every clone of the unsealed base would share one machine identity. Allowed in a variant build or a layered from. | The base. |
Exactly one variant is marked default = true, and a lone variant is the default without the mark. On disk a variant is a qcow2 overlay at <version>/variants/<name>/disk.qcow2, whose backing file is the base, ../../disk.qcow2, by relative path. Its block in the version's template.wcl records its digest, the digest of the base it was built on, and any fields it overrides: hardware, the first-boot script, the wscript surface and the agent version. A clone of a variant runs on the base's metadata with the variant's fields in their place. If the base is rebuilt, a variant built on the old one is stale and refused until it is rebuilt too.
vmlab template list shows each variant on its own line under its version, marks the default and flags a stale one. vmlab status -v shows the variant each VM runs, including the one a reference without # picked. vmlab template rm <arch>/<name>@<version>#<variant> removes one variant; removing the version removes all of them.
Variants are declared as variant "<name>" { } blocks inside the template {} block, each with its own provisions and any hardware it changes; Lab file: template and source has the fields. vmlab template build seals the base, then builds each variant on it: an overlay on the base, booted on the variant's hardware, running the variant's steps and no first-boot script, shut down and installed beside the base. vmlab template build <name> --variant <v> rebuilds one variant onto the installed version without touching the base or the others.
A build that needs a GPU declares gpu { mode = "vulkan" } on the template or the variant, the same block a VM carries. The build VM then gets a host GPU exactly as a lab VM does, named with host_gpu or the host's default, and the build log says which (host_gpu=<name>). The block is for the build VM only; the template records nothing about it, so clones choose their own GPU, or none.
The base of a template with variants is unsealed, so a lab never clones it. The build marks it variants_expected = true in its metadata. Until the default variant is installed, every lab reference to that version is refused, with or without #, and the message gives the command that builds the variants: `vmlab template build <name>@<version> --variant <variant>`. This covers a build whose variants all failed and a version whose overlays were deleted. vmlab template list shows such a version as variants not built. The marker travels with a push, pull, export and import. A vmlab release older than the marker pulls such a base as an ordinary base-only template.
The store
The store lives at ~/.local/share/vmlab/templates/, laid out as <arch>/<name>/<version>/. Reads are lock-free. Every mutation, whether an install, a removal or an import, holds an exclusive lock on the store, and content only ever enters it by an atomic rename of a fully staged directory. A build or import that fails part way leaves nothing behind.
Since the supervisor became the store's owner, every vmlab template verb is a protocol client: the CLI reads its own surroundings, such as a relative path or the git remote of the current directory, and sends them to the supervisor, which is the only process that opens the store or dials a registry. Two consequences follow. A build started in one terminal can be listed and stopped from another with `vmlab template stop`. And a build needs a supervisor, which the CLI starts for you.
vmlab template list shows what is installed. rm removes one version and refuses, without --force, when that disk still backs a linked clone in some lab. clean prunes superseded builds per family, keeping the newest by default, and is a dry run until you pass --yes.
Building a template
Before building, check whether the template is already published: pulling one takes minutes, building it can take much longer. A template is declared in a template block, in its own WCL file or beside a lab, and built with vmlab template build. The block names the build's source, the hardware the build VM boots with, any media to attach, and the provision scripts and playbooks that drive the install.
// Ubuntu Server 24.04 template (PRD §6.1). The installer ISO is downloaded
// and sha256-verified into the artefact cache; cloudinit/ is packed into a
// CIDATA ISO that subiquity picks up as a NoCloud autoinstall source. The
// provision script answers the autoinstall confirmation and waits for the
// installer to power the VM off. Build with:
//
// vmlab template build (run from this directory)
import <vmlab.wcl>
template "ubuntu-24.04" {
arch = "x86_64"
version = "24.04.4"
profile = "linux-modern"
cpus = 2
memory = 4GiB
disk = 20GiB
source "iso" {
url = "https://releases.ubuntu.com/24.04/ubuntu-24.04.4-live-server-amd64.iso"
sha256 = "e907d92eeec9df64163a7e454cbc8d7755e8ddc7ed42f99dbc80c40f1a138433"
}
media { kind = "iso" from = "./cloudinit/" label = "CIDATA" }
nic { nat = true }
provision "scripts/install.ws" { }
}
The source is exactly one of four forms:
- iso — an installer ISO, by local path or by URL with a required sha256. A URL is downloaded into the artefact cache and verified before use.
- qcow2 — an existing disk image, by path or URL and hash, imported as the base.
- template — another template in the store, named by from. A layered build starts from that template's disk and its recorded hardware, runs more provisioning, and seals a new template.
- scratch — a blank disk of the declared disk size. The attached media and the provision script do everything.
The build runs as a synthetic one-VM lab: a scratch VM whose primary disk is pre-seeded from the source, on the hardware the block declares over the source template's recorded hardware. It reuses the whole lab runtime, so a build script has the same API a lab provision has. The flow is: create the working qcow2, boot it, run the provision steps, wait for the guest to shut down, verify the agent's handshake, then move the disk and its metadata into the store under the new version. Build output streams to your terminal as it happens.
The agent bootstrap ISO
vmlab attaches one extra ISO to every build VM, labelled VMLAB. It carries the agent binaries and an install script per OS. The template's own unattended-install hook, such as a cloud-init runcmd, a subiquity late command, or an autounattend first-logon command, mounts that ISO and runs the script. The guest installs the agent itself, so no host channel is needed before the agent exists, and the build verifies the handshake before sealing. Clones never see this ISO. Set agent = false on the block to skip baking the agent, at the cost of a template that never reports ready.
The build VM always runs with prevent_sleep on. A build is unattended, and a guest that idles into ACPI S3 mid-install, as a Windows client edition does, would stall it exactly like a hang; instead the lab daemon wakes it the moment it sleeps.
For an x86 guest the ISO also carries the legacy agent (see Guest OS profiles, agent_transport = "isa-serial") under legacy/nt, legacy/9x and legacy/dos, every name 8.3 because DOS reads no Joliet. install.cmd defers to install-nt.cmd on a 4.x or 5.x kernel; install-9x.bat registers the agent under RunServices; INSTALL.BAT appends it to FDAUTO.BAT on FreeDOS (which never reads AUTOEXEC.BAT), else to AUTOEXEC.BAT, and starts it. A DOS or 9x template has no unattended hook, so its provision types the install line over the screen, for example D:\INSTALL.BAT from the CD or A:\INSTALL.BAT from a floppy media block. The scripts probe drives A, D, E and F for their source.
Versions
The block's version is a fixed prefix that identifies the upstream release. vmlab appends a build counter, so building the Ubuntu example above once produces 24.04.4.0 and again 24.04.4.1. The counter continues from the highest tag already published when the block names a registry, and from the local store otherwise. Change the prefix and the counter restarts at zero. Pass --version to pin a version instead; a version already in the store is refused.
A failed build leaves nothing in the store
The build's working directory is removed on success and on failure, and the supervisor sweeps any build directory a killed process left behind the next time it starts. A supervisor restart fails every build in flight; there is no resumption.
Media built from folders
A media block turns a folder on disk into an ISO or a floppy image and attaches it to the build VM, or to a lab VM. This is how unattend files, driver bundles and payloads reach a guest that has no network. Built images land in the lab's .vmlab/media and are content-addressed by a digest over the folder's contents, the kind and the label, so an unchanged folder never rebuilds.
ISOs are built with xorriso, falling back to genisoimage and then mkisofs, with Joliet and Rock Ridge extensions so both Windows unattend media and Linux payloads read correctly. Floppies are 1.44 MB FAT12 images built with mformat and mcopy from mtools. A disk block with from does the same for a larger FAT disk.
Linked clones and scratch VMs
vmlab up creates each VM's disk as a qcow2 overlay whose backing file is the template's disk in the store: the variant's overlay for a template with variants, which is itself backed by the base. The template is never written to. The reference the clone was made from, #<variant> included, is recorded beside it, and up refuses to reuse the clone for a different one. Clones live in .vmlab/, survive down, and are deleted by destroy, after which the next up makes fresh ones. Clones grow as the guest writes, which is why the supervisor watches free space on the filesystems holding .vmlab/ and the store and emits host.disk_low.
template = "scratch" is a reserved name meaning no backing image. The VM gets a freshly created blank qcow2 instead of a clone, and its hardware chain collapses to VM block then profile. Validation therefore requires an explicit arch, profile and disk. Boot media is yours to supply, typically a cdrom or a media block. scratch never appears in the store and cannot be pushed or pulled. It is for installer development and OS builds, where starting with no OS is the point.
Moving a template between machines
Two paths carry a template off the host it was built on. Both preserve the metadata, so a moved template inherits into VMs exactly as it did at home.
- Export and import. vmlab template export writes one portable .tar.zst archive holding the disk, its metadata and every variant's overlay, and import installs it into another store after checking each variant against its digest and its base. This is the offline path.
- Registries. vmlab template push publishes a template to any OCI registry, and pull installs it from one. Distributing templates over registries explains how a template is laid out as an OCI artifact.
A lab's template = may also name a registry reference directly, with an explicit arch on the VM. vmlab up pulls it when it is absent from the store and never re-pulls it implicitly. Updates are explicit, through vmlab pull or vmlab template pull. A registry reference takes the same #<variant> suffix as a store reference. A pull always brings the template's default variant along with the base, and adds a variant a machine names when the store's copy of the version lacks it, without downloading the base again.