Skip to content

Lab environments

Every command in a tutorial or how-to guide is run in a disposable virtual machine (a lab) before the page is published. This page describes the parts that make that work, how to build and control them, and what to do when something goes wrong. For writing verifiable pages, see Command verification; the design is recorded in ADR-009, ADR-010, ADR-016, ADR-017 and ADR-018 (docs/plan/adr/).

Requirements

  • A Linux host with KVM: your user can open /dev/kvm (usually through the kvm group).
  • QEMU (qemu-system-x86_64 and qemu-img), xorriso, and OpenSSH (ssh, ssh-keygen).
  • UEFI firmware (OVMF) for the Gentoo lab: /usr/share/OVMF/OVMF_CODE_4M.fd and OVMF_VARS_4M.fd, or set MAGELLANIC_OVMF_CODE and MAGELLANIC_OVMF_VARS.
  • Optional: Docker with the Compose plugin, for the package cache.
  • Disk space: about 5 GB for the official images, 4 GB per baked Gentoo image and its cached first stage, and up to 40 GB for the package cache.
  • Memory: 2 GiB per lab, 6 GiB for a Gentoo build.

make install sets up everything else (Python toolchain, hooks).

Components

Component Where What it does
Image catalogue labs/images.toml Official cloud image per distribution, its checksum source, family and firmware; Gentoo's build recipe; the concurrent lab limit ([lab] max_vms)
Lab runner scripts/lab.py Downloads and verifies images, builds and accepts them, boots labs, runs commands over SSH
Verification harness scripts/verify_commands.py Runs a page's commands in labs on each listed distribution, records results, updates front matter
Acceptance checks labs/common/accept.sh, labs/<distro>/accept.sh Prove an image is fit to use before any lab runs on it
Gentoo build recipe labs/gentoo-openrc/bake-1-world.sh, bake-2-configure.sh Convert Gentoo's official systemd image to OpenRC, in two stages
Package cache labs/cache/ A caching proxy for package downloads (nginx in Docker), and guest.sh, which points a lab's package sources at it
Lab cache ~/.cache/magellanic/labs/ Images, results, logs and state on your machine; see Files on your machine

How a lab starts

  1. Image. lab.py uses the frozen image if images are frozen. Otherwise it looks up the current official image, downloads it once and verifies it against the publisher's checksum. For Gentoo it builds the lab image from the official one (see Building the Gentoo image).
  2. Acceptance. Before an image is first used, and whenever its checks change, it is booted once and its acceptance checks run. An image that fails isn't used.
  3. Slot. The lab waits for one of max_vms slots, shared by every process on the host.
  4. Boot. A copy-on-write overlay on the image boots under QEMU/KVM with a cloud-init seed: a user, labuser, with passwordless sudo and a one-time SSH key. The first NIC reaches the internet through QEMU's user-mode network, IPv4 only (its IPv6 has no route out and only slows guests down). Extra NICs sit on an isolated segment and stay unconfigured: on NetworkManager images (network_manager = true), /etc/NetworkManager/conf.d/90-magellanic-lab.conf turns off its automatic DHCP profiles, written early in boot from a systemd credential. Extra disks are blank.
  5. Wait. lab.py waits for SSH and cloud-init, watching the serial console for fatal errors. The limit is three times the image's measured boot time (at least two minutes).
  6. Package cache. If the cache is running and the page allows it, the lab's package sources are pointed at it.
  7. Run. Commands run over SSH, as labuser or as root. Each block's script and output are logged.
  8. Clean-up. The lab and its files are deleted, unless you asked to keep them or it failed to boot.

Build

Official images

scripts/uv run python scripts/lab.py fetch                # download and verify every image
scripts/uv run python scripts/lab.py fetch debian-13      # one image
make lab-accept                                           # run the acceptance checks on every image

New official images are picked up automatically whenever a lab starts (unless images are frozen). Arch and Gentoo are rolling: their identifiers carry a snapshot date, such as arch@2026-11-01.

Building the Gentoo image

Gentoo publishes no OpenRC cloud image, so the project builds one (ADR-016) in two stages (ADR-017):

  1. Stage 1 (bake-1-world.sh, the slow part: about 10 minutes) runs as root in a builder VM booted from the official image. It syncs the Portage tree (its date becomes the image's snapshot date), switches to the OpenRC profile, rebuilds @world and removes systemd. The builder is powered off through SysRq (the old init system is gone), and the filesystem is repaired offline in a Debian helper lab. The result is cached.
  2. Stage 2 (bake-2-configure.sh, a few minutes) runs in a chroot of the cached stage 1 image from a Debian helper lab: runlevels, networking, the initramfs and GRUB, and per-instance state.
  3. Settle boot. The result boots once under OpenRC and shuts down cleanly, so every lab starts from a clean filesystem.
  4. Acceptance. labs/gentoo-openrc/accept.sh proves PID 1 is OpenRC's init, no systemd remains in the initramfs or cgroups, DNS works, the filesystem is clean, and the kernel tools are installed. A failing image is renamed ….rejected.qcow2 and not used.

A build starts automatically when a Gentoo lab is needed and no current image exists. To build ahead of time:

scripts/uv run python scripts/lab.py fetch gentoo-openrc

Caching is by content: stage 1 is keyed by the official image, the stage 1 recipe and BAKE_VERSION (in scripts/lab.py); the final image also by stage 2. So:

  • changing bake-2-configure.sh rebuilds only stage 2;
  • changing bake-1-world.sh, a new official image, or a new BAKE_VERSION rebuilds both;
  • bump BAKE_VERSION when you change how lab.py builds images, not when you change a recipe.

Only one build or acceptance run per distribution happens at a time; other processes wait for it.

Writing acceptance checks

Write the check before the change it proves. Every check must be able to fail:

  • assert that the tool exists and that its output isn't empty before interpreting it;
  • capture output in a variable, then match it (never command | grep -q under pipefail);
  • print PASS … or FAIL … for each check and exit non-zero on any failure (WARN … is reported but doesn't fail);
  • try a new check once against a broken image or input, and confirm it fails.

labs/common/accept.sh applies to every image; add labs/<distro>/accept.sh for checks one image needs. Changing either re-runs acceptance on the next use.

Control

Everyday commands

Task Command
An interactive lab scripts/uv run python scripts/lab.py shell debian-13 (add --nics 2 --disks 1)
Smoke-test every image make lab-check
List cached images and their state scripts/uv run python scripts/lab.py list
Run acceptance checks make lab-accept (RECHECK=1 to repeat passed checks)
Verify a page make verify PAGE=docs/how-to/…/page.md (SKIP=gentoo-openrc while Gentoo is verified centrally)
What would run where scripts/uv run python scripts/verify_commands.py PAGE --plan
One distribution only, keeping lab files scripts/uv run python scripts/verify_commands.py PAGE --distro arch --keep
Results against current images make verify-status
Pages still needing a distribution scripts/uv run python scripts/verify_commands.py --needs gentoo-openrc
Verify them all make verify-central (DISTRO=…, default gentoo-openrc)

Freezing images for a wave

make lab-freeze       # pin the current, accepted images; record the output in the wave manifest
make lab-unfreeze     # release the freeze between waves

While images are frozen, labs never download or build a newer image, so results stay comparable across a wave. lab.py list marks frozen images. The freeze is stored on your machine (freeze.json); record what you froze in docs/plan/phase-3-manifest.md.

Capacity

The number of labs that may run at once on a host is [lab] max_vms in labs/images.toml. Measure it rather than guessing:

make lab-loadtest     # boots 1, 2, 4, 6… labs with a fixed workload; writes the result to labs/images.toml

It recommends the largest count whose boot and workload times stay within 25 % of a single lab's, capped so a fifth of available memory stays free. Stop other lab work first. MAGELLANIC_LAB_MAX_VMS overrides the setting for one shell.

The package cache

make lab-cache-up        # start it (127.0.0.1:3142 on the host)
make lab-cache-status    # health, size, and cache hits versus misses since it started
make lab-cache-down      # stop it, keeping its contents
make lab-cache-purge     # stop it and delete its contents

While it runs, each lab's package sources (APT, dnf, pacman and Portage) point at it (labs reach the host at 10.0.2.2), so each package is downloaded once. Index files are always fetched fresh; packages are kept for 60 days, up to 40 GB. Package managers still verify every signature. Inside a lab, /etc/magellanic-lab-cache lists the files that were changed, and the originals are under /var/lib/magellanic-lab-cache/orig. Image builds, settle boots and acceptance runs never use the cache.

Pages that show or change repository configuration opt out with <!-- verify: no-cache -->. To add a mirror, add a location to labs/cache/nginx.conf and a matching rule to labs/cache/guest.sh.

Environment variables

Variable Default Effect
MAGELLANIC_LAB_CACHE ~/.cache/magellanic/labs Where images, results, logs and state are kept
MAGELLANIC_LAB_MAX_VMS [lab] max_vms Concurrent lab limit
MAGELLANIC_LAB_PKGCACHE on off keeps every lab on its stock mirrors
MAGELLANIC_LAB_PKGCACHE_HOST, MAGELLANIC_LAB_PKGCACHE_GUEST http://127.0.0.1:3142, http://10.0.2.2:3142 Package cache address from the host and from labs
MAGELLANIC_LAB_SLIRP_IPV6 off on gives the first NIC QEMU's user-mode IPv6 (no route out; slows boots)
MAGELLANIC_OVMF_CODE, MAGELLANIC_OVMF_VARS /usr/share/OVMF/OVMF_*_4M.fd UEFI firmware for the Gentoo lab

Files on your machine

All under ~/.cache/magellanic/labs/ (or MAGELLANIC_LAB_CACHE):

Path Contents
images/<distro>/<digest>.qcow2, .json Official image and its metadata (URL, checksum, snapshot, acceptance)
images/<distro>/<digest>-stage1-<key>-v<N>.qcow2, .log, .snapshot Gentoo stage 1: cached image, build output, Portage snapshot date
images/<distro>/<digest>-baked-<key>-v<N>.qcow2, .json, .log Built lab image, its metadata, and the chroot stages' output
images/<distro>/*.accept.log Output of the last acceptance run on that image
images/<distro>/*.rejected.qcow2 A built image that failed acceptance (kept for diagnosis)
run/<distro>-<random>/ A running lab: console.log (serial console), qemu.log, overlay disk, seed, SSH key
logs/<page>/<time>/<distro>.log Every block's script and full output for one verification run
results/<page>.json Result records: per distribution, the image, the page text's hash, pass or fail, log path, whether the package cache was used
boot-times.json The last five boot-to-SSH times per image (sets the boot wait)
freeze.json Frozen images
slots/, images/<distro>/*.lock Locks shared by every process; safe to leave

Run directories are deleted when a lab stops, unless you passed --keep or the lab failed to boot.

Troubleshoot

Start with the message lab.py prints: it names the failure and the log to read.

Symptom Likely cause What to do
boot failed: the firmware found no bootable disk Wrong firmware for the image, or a damaged image Check firmware in labs/images.toml (Gentoo needs uefi and OVMF); delete the image and its .json to download it again
boot failed: kernel panic or initramfs couldn't find the root filesystem A broken image; for Gentoo, usually an initramfs problem Read console.log in the kept run directory; for Gentoo, rebuild (stage 2 regenerates the initramfs) and check the acceptance log
boot failed: emergency shell or systemd emergency mode A filesystem or fstab problem in the image Read console.log; for a built image, check the stage 1 and chroot logs
boot failed: no writes to the root disk after 60s The guest never started: KVM unavailable, firmware stalled Check you can open /dev/kvm; read qemu.log
boot failed: QEMU exited QEMU couldn't start the VM Read qemu.log in the run directory
AlmaLinux or Rocky lab with extra NICs boots a minute slower, NetworkManager-wait-online failed NetworkManager is configuring an extra NIC by DHCP Check the image has network_manager = true and the lab has /etc/NetworkManager/conf.d/90-magellanic-lab.conf
SSH not reachable after …s The lab booted but networking, SSH or cloud-init didn't come up; or the host is overloaded Read console.log. If the host was busy, retry; to reset the measured boot times, delete boot-times.json
all N lab slots busy; waiting Other labs hold every slot Wait, or check for stray labs (below); measure capacity with make lab-loadtest
another process is baking or checking this image; waiting A build or acceptance run is in progress elsewhere Wait; it finishes, and this process reuses the result
… failed acceptance checks: FAIL … The image isn't fit to use Read the .accept.log named in the message; fix the recipe (or report an official image problem) and run make lab-accept
stage 1 failed or a chroot stage failed The Gentoo recipe stopped Read the .log next to the image (stage 1: …-stage1-….log; stage 2: …-baked-….log); the recipes run with set -x, so the last command shown failed
frozen image … is missing The frozen image was deleted scripts/uv run python scripts/lab.py unfreeze <distro>, then freeze again
package cache not running The cache isn't started (not an error) make lab-cache-up, or ignore it: labs use their stock mirrors
package cache not applied No package sources in the lab matched the rewrite rules Labs fall back to stock mirrors; compare the lab's source files with labs/cache/guest.sh
Package installs fail only with the cache An upstream or rule problem in the cache curl -sI http://127.0.0.1:3142/<prefix>/… shows X-Magellanic-Cache; docker compose -f labs/cache/compose.yaml logs nginx; retry with MAGELLANIC_LAB_PKGCACHE=off to confirm
A block fails in verification The page's command or the lab Read the full log printed with the failure; reproduce in lab.py shell
make verify passes but front matter isn't updated Another listed distribution has no current pass The output names it; make verify-status shows the state of every page

Stray labs and leftovers

A lab stopped abruptly (a killed terminal, a crashed process) can leave a QEMU process and a run directory behind. Find lab VMs by their run directory, and stop only those, never every QEMU process on the host:

pgrep -af 'magellanic/labs/run'             # lab VMs still running
pkill -f 'magellanic/labs/run/<distro>-'    # stop one lab VM
ls ~/.cache/magellanic/labs/run/            # leftover run directories

Delete a leftover run directory once no process uses it. Old images, rejected images and logs can be deleted at any time; lab.py downloads or rebuilds what it needs.

Keeping a lab for inspection

scripts/uv run python scripts/verify_commands.py PAGE --distro rocky-10 --keep

The run directory is kept and its path is printed. A lab that failed to boot is always kept.