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 thekvmgroup). - QEMU (
qemu-system-x86_64andqemu-img),xorriso, and OpenSSH (ssh,ssh-keygen). - UEFI firmware (OVMF) for the Gentoo lab:
/usr/share/OVMF/OVMF_CODE_4M.fdandOVMF_VARS_4M.fd, or setMAGELLANIC_OVMF_CODEandMAGELLANIC_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¶
- Image.
lab.pyuses 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). - 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.
- Slot. The lab waits for one of
max_vmsslots, shared by every process on the host. - 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.confturns off its automatic DHCP profiles, written early in boot from a systemd credential. Extra disks are blank. - Wait.
lab.pywaits 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). - Package cache. If the cache is running and the page allows it, the lab's package sources are pointed at it.
- Run. Commands run over SSH, as
labuseror as root. Each block's script and output are logged. - 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):
- 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@worldand 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. - 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. - Settle boot. The result boots once under OpenRC and shuts down cleanly, so every lab starts from a clean filesystem.
- Acceptance.
labs/gentoo-openrc/accept.shproves 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.qcow2and not used.
A build starts automatically when a Gentoo lab is needed and no current image exists. To build ahead of time:
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.shrebuilds only stage 2; - changing
bake-1-world.sh, a new official image, or a newBAKE_VERSIONrebuilds both; - bump
BAKE_VERSIONwhen you change howlab.pybuilds 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 -qunderpipefail); - print
PASS …orFAIL …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¶
The run directory is kept and its path is printed. A lab that failed to boot is always kept.