FUGUVM(1)

NAME
fuguvm - install and manage OpenBSD virtual machines

SYNOPSIS
fuguvm [-hq] [- -vm name] [- -project dir] [- -emulate] command [argument ...]

DESCRIPTION
fuguvm installs and manages OpenBSD virtual machines under QEMU. It downloads the installation image, creates the disk image, and answers the installer over the serial console. It then controls the machine's lifecycle, guest access, and console automation. You need only one command to go from nothing to a machine that accepts ssh(1). A second run of the command does nothing.

The guest architecture is fixed: every VM is an arm64 (aarch64) machine.

The host does not have to run OpenBSD. A host that runs QEMU is sufficient, and fuguvm selects the accelerator to match the host. Thus a Linux or Darwin workstation can build, test, or reproduce a bug on a real OpenBSD system without an OpenBSD machine on the desk.

fuguvm operates on a project: a directory that has an .fuguvmrc configuration file and an .fuguvm/ state directory. The init command creates them. If you do not give - -project, fuguvm searches up from the current directory for an .fuguvmrc file. The directory that holds this file is the project root.

The options are:

- -vm name
    Operate on the named VM. The default comes from the default_vm directive, or it is “default”. VM definitions are in .fuguvm/vms/name.conf.

- -project dir
    Use dir as the project root, and do not search for it.

-q, - -quiet
    Do not show informational output.

- -emulate
    Use TCG software emulation, not hardware acceleration. Without this flag, fuguvm selects the accelerator automatically: HVF on macOS, KVM on aarch64 Linux hosts with /dev/kvm, and TCG on all other hosts.

-h, - -help
    Show the usage message.

These commands are available:

init [dir]
    Initialize a project in dir (default: the current directory). The command creates dir if it is absent. It also creates the .fuguvmrc project configuration, the .fuguvm/ directory with the vms/ and state/ subdirectories, and a default VM configuration. The command is idempotent: it does nothing if the project is already initialized.

up [- -no-cache]
    Make sure that the VM runs. The command downloads the installation image, creates the disk image, and starts the VM, as necessary. The command is idempotent.

    If the installed-image cache is on, up uses both sides of that cache. If there is no disk image, up creates one as an overlay on a cached base that matches, and boots the installed system directly. This skips the miniroot download and the installer. After an installation, up publishes the newly installed disk as a new cached base. The cache operation is best effort: a failure causes a warning, and the VM starts, with an installation from scratch or with a standalone disk.

    - -no-cache skips the restore and the save for this run. This causes a fresh installation and does not change the cache. The flag overrides the image_cache directive. Use it to debug the installer itself.

down
    Stop the VM gracefully.

destroy
    Stop the VM and delete its disk image.

start
    Start the VM in the background.

stop [-f, - -force]
    Stop the VM. With -f, stop it with force.

status
    Show the VM status as key: value lines. The lines include ssh_port and console_port.

ssh [command]
    Open an interactive SSH session to the VM as root, or run command and exit with its exit code. Authentication uses the SSH agent.

console
    Show how to connect to the VM serial console (with telnet(1)).

expect script [argument ...]
    Run an expect(1) script against the VM console.

wait [- -timeout=seconds]
    Block until the VM accepts SSH connections (default timeout: 120 seconds).

cache list | clear [- -stale]
    Manage all data under cache_dir: the installed-image cache that IMAGE CACHE describes, and the downloads that the built-in proxy keeps for the installer. list shows one line for each cached image (key, size, creation time, snapshot count). It puts a mark on the entry that the invoked VM's configuration derives at this time. Then it shows the proxy's downloads by OpenBSD version. clear removes each image and each download. - -stale keeps only the image that the invoked VM derives, and only the downloads for the OpenBSD version that it installs. Both forms also remove temporary trees that an interrupted save leaves behind.

    This removal is the only limit on the downloads. Their key is the OpenBSD version alone, and it has no input in common with the image keys. Each pattern that the proxy caches has a version scope. Thus, after a version change, the file sets of the previous version stay in the cache until you remove them, and nothing reads them again.

    The scope of - -stale is one VM: the VM that - -vm names. Each VM has its own version and disk_size key inputs. Thus a removal for one VM removes bases that a different VM can use. It also removes the downloads for the OpenBSD version that a different VM installs.

    clear does not remove an entry while a VM in this project runs on it. It gives a warning when a removal orphans the disk of a stopped VM (rebuild it with destroy and up). clear cannot examine the VMs of other projects that share the same cache_dir, and the two checks do not apply to them.

snapshot save | restore | rm name

snapshot list [- -names]
    Manage named snapshot layers over the cached base image. Use them to cache guest states that fuguvm does not know about (usually the result of a provisioning script). See IMAGE CACHE.

    save flattens the working disk into the cache, under the base image that the disk uses. restore replaces the working disk with a new overlay on name, and reseeds the VM state that the snapshot records. Stop the VM before both operations: a live overlay is not consistent. save also needs an installed VM with a disk that is built on a cached image. You cannot snapshot a standalone disk (from - -no-cache or image_cache no). restore needs no disk and no state, and it can run on a fresh checkout before the first up.

    A snapshot that is absent or unresolvable causes an exit with code 11, not 1. Thus a script can see the difference between this and a real failure:

        fuguvm snapshot restore deps-$hash || provision_from_scratch

    The scope of list and rm is the cache entry that the current configuration derives. list - -names writes bare snapshot names to standard output, one name on each line, for scripts. Without the flag, the output goes to standard error, the same as all other informational output.

disk check | repair | info
    Manage the VM disk image. check does a check of its integrity. repair repairs errors. Stop the VM before you run repair. info shows the image details.

help
    Show the usage message.

IMAGE CACHE
An installation of OpenBSD under software emulation takes tens of minutes. Thus fuguvm keeps the result: a clean, compacted copy of the disk. fuguvm makes the copy immediately after the installation is complete. Later runs use the copy as the backing image of a throwaway overlay. With a warm cache, destroy and then up are a cheap factory reset.

Entries are under cache_dir, adjacent to the proxy cache that holds the miniroot and the install sets:

    <cache_dir>/proxy/...                            miniroot, install sets
    <cache_dir>/installed/<key>/base.qcow2           mode 0400, compacted
    <cache_dir>/installed/<key>/meta.json            mode 0600
    <cache_dir>/installed/<key>/snapshots/<n>.qcow2  mode 0400
    <cache_dir>/installed/<key>/snapshots/<n>.json   mode 0600

key is version-arch-hash. The hash part is a truncated SHA-256 over all inputs that shape an installed disk. These inputs are: the OpenBSD version, the architecture, disk_size, the contents of the install.exp installer script, and the contents of share/fuguvm/cache-generation. The memory and port settings do not shape the disk, and the hash excludes them. Thus a change to them hits the same entry.

share/fuguvm/cache-generation is a generation counter. Increase the number in the file when the procedure that drives the installer changes outside install.exp. This makes the cached images invalid. Keep only the bare number in the file: the hash includes the whole file.

Entries are immutable and write-once. fuguvm builds each entry whole in a temporary directory, and then publishes the entry with a rename of that directory. Thus a reader never sees the base image of one installation adjacent to the metadata of a different installation.

A snapshot is an immutable, named layer over the base image of an entry. snapshot save creates the snapshot from a stopped VM. fuguvm flattens each snapshot onto base.qcow2. Thus each snapshot is a direct child of the base, and the initial backing of the working disk has no effect:

    base.qcow2  <-  <name>.qcow2  <-  disk.qcow2

Thus no snapshot is the parent of a different snapshot, and the removal of one snapshot cannot orphan a different snapshot. The snapshots are inside the entry. Thus each operation that makes a base invalid (a new key, or cache clear) also makes the snapshots of that base invalid.

FILES
.fuguvmrc
    Project configuration: cache_dir, state_dir, default_vm, image_cache.
.fuguvm/vms/name.conf
    Per-VM configuration: name, version, memory, disk_size, ssh_port, console_port.
.fuguvm/state/
    Runtime state (PID files, sockets, disk images).
~/.cache/fuguvm/
    Default cache directory: downloaded installation images and the installed-image cache.
share/fuguvm/cache-generation
    Generation counter for the installed-image cache.

image_cache yes | no (default yes) switches the installed-image cache on or off. fuguvm reads the directive from the project .fuguvmrc or the global ~/.fuguvmrc, with the project first. The directive can also be in a vm block. - -no-cache on up overrides all of them.

A line that the configuration grammar does not accept is an error that names the file and the line, and fuguvm exits with 3. The parser never skips a line it did not understand: a typo that a parser ignores is a setting that silently does not apply, and the operator finds out weeks later from the behavior of the VM.

A value for image_cache that is neither yes nor no gives a warning and falls back to the default. An unrecognized spelling must not silently mean the opposite.

EXIT STATUS
fuguvm exits with one of these values:

0   Success.
1   General error.
2   Invalid arguments.
3   Configuration error, or not in an fuguvm project.
4   VM not found.
5   The VM runs.
7   Timeout in the wait for the VM.
9   Expect script failed.
11  Snapshot not found.

The ssh command with a command argument exits with the exit code of the remote command.

EXAMPLES
Initialize a project and boot the default VM:

    $ fuguvm init
    $ fuguvm up
    $ fuguvm wait --timeout=300

Run a command in the VM:

    $ fuguvm ssh "uname -a"
    OpenBSD openbsd-default.my.domain 7.8 GENERIC arm64

Operate on a VM other than the default:

    $ fuguvm --vm minimal up

SEE ALSO
expect(1), ssh(1), telnet(1)

HISTORY
The fuguvm utility was first available in 2025. Its initial purpose was to run the test suite of a daemon against a real OpenBSD guest. It became more general from there.

AUTHORS
Dick Olsson <hi@senzilla.io>

SECURITY CONSIDERATIONS
fuguvm generates a random root password for each installation and stores it in the VM state directory. When the installed image goes into the cache, fuguvm also stores the password in the meta.json of that entry (mode 0600). The cache makes the life of this secret longer: the password stays after destroy, and it changes only when the base key changes.

This is not a localhost-only secret. The guest permits password root login. fuguvm gives QEMU the ssh_port and console_port values with no bind address. Thus the forwarded SSH port and the telnet serial console listen on each host interface. Treat a VM that runs as open to all persons who can reach the host. Apply the same rule to a cache_dir that has content.