Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail Ckclackatoo PrProject WiWispist
Using Peios Security Basics Technical Documentation Source
Using Peios Security Basics Technical Documentation Source
Provium

Configuration

Single-page view · as markdown

provium.toml

Provium / Configuration

provium.toml is the only config file Provium reads. The default location is ./provium.toml; override with --config <path>.

The schema has two top-level keys: [provium] for runner settings, and [profiles.<name>] for per-VM-type tuples. Both are optional individually — an empty provium.toml parses cleanly but won't be useful for anything (no profiles means provium:vm(name, profile) won't find any profile to look up).

Minimum useful config #

[provium]
roots = ["tests"]

[profiles.peios]
kernel  = "/path/to/bzImage"
initrd  = "/path/to/initrd.cpio.gz"
cmdline = "console=ttyS0 quiet"

This declares one test root and one profile. provium tests/ will scan tests/ for *.test.lua, and tests can call provium:vm("name", "peios").

[provium] section #

roots #

[provium]
roots = ["tests", "vendor/upstream-tests"]
TypeDefaultDescription
array of strings[] (empty)Directories scanned for *.test.lua and *.fixture.lua files. Also prepended to package.path so require("helper.module") resolves under any root.

When roots is empty (or unset), provium <paths> scans whichever paths you pass on the CLI; if you also omit those, it scans the current directory.

For most projects, set roots = ["tests"] (or whichever directory contains your tests) so that:

  • provium (no arg) walks the test tree.
  • provium fixture build foo finds tests/foo.fixture.lua.
  • Tests can require("helpers.assert_pingable") and Provium resolves it as tests/helpers/assert_pingable.lua.

cache_dir #

[provium]
cache_dir = "/var/cache/provium/fixtures"
TypeDefaultDescription
path string~/.cache/provium/fixturesWhere fixture snapshots are stored.

Useful for:

  • Per-machine caches on shared hosts (default is per-user, set to a system-wide path for shared CI runners).
  • Fast scratch storage (point at an SSD or tmpfs for build performance).
  • Large dedicated cache (point at a partition with more headroom than ~/.cache).

The directory is created on first build. If the path is unreadable / unwritable at startup, eviction silently skips and the next build attempt errors with the underlying I/O error.

cache_max_size #

[provium]
cache_max_size = "100G"
TypeDefaultDescription
string with K/M/G/T suffix, or bare bytes"20G"LRU eviction target. The cache is allowed to grow beyond this between runs; eviction trims at the next provium startup.

When the cache exceeds the cap, Provium sorts entries by access time and deletes oldest until total size is under the cap. Each successful restore bumps the entry's atime so popular fixtures stay hot.

[profiles.<name>] blocks #

Each [profiles.<name>] block declares one (kernel, initrd, cmdline, guest_os) tuple. Test code looks them up by name: provium:vm("v", "<name>").

You can have any number of profiles; tests pick whichever they need.

kernel #

[profiles.peios]
kernel = "/build/peios/bzImage"
TypeRequiredDescription
path stringone of kernel / rootPath to a bzImage-format kernel image. Booted via QEMU's -kernel option.

Path validation (does the file actually exist?) happens at VM-boot time, not config-load time. This lets a single provium.toml be portable across machines that have different kernel layouts.

May be omitted when root names a composed Peios root, in which case the kernel is found inside it.

root #

[profiles.kernel-only]
root = "{out}/root"
TypeRequiredDescription
path stringone of kernel / rootA composed Peios root — what peiso root writes. The kernel is taken from usr/lib/modules/<release>/vmlinuz-<release> inside it.

Set this instead of kernel when the profile's build composes a root, so the profile does not also have to know where in that root a kernel lands. A kernel version bump then moves nothing in the config. kernel, when set, wins.

Resolution happens at VM-boot time, after the build has run — which is the only order that works, since the build is what produces the root. Two kernels in one root is an error rather than a choice made quietly: set kernel to say which.

initrd #

[profiles.peios]
initrd = "/build/peios/initrd.cpio.gz"
TypeRequiredDescription
path stringnoPath to an initramfs. Booted via QEMU's -initrd. Omit it to boot the agent overlay alone — see No initrd at all.

By default, Provium injects the provium-agent binary at /sbin/provium-agent by concatenating a small overlay cpio onto your initrd at launch (the kernel unpacks concatenated gzip cpios into a single rootfs). The agent boots as PID 1 and forks immediately: the child becomes the vsock listener, the parent execs your initrd's /init, which therefore takes over PID 1. In this chained layout the agent deliberately mounts nothing first — your init owns the mount and pivot sequence exactly as it would alone. (Only on an agent-only initrd, with no user /init, does the agent perform the pseudo-FS mounts itself.) Userspace runs as it would have on its own; the agent runs alongside as PID 2.

Three consequences:

  • Your initrd doesn't need to know about Provium. A vanilla distro initramfs, a buildroot image, or a from-scratch cpio with just /init will all work.
  • Your /init runs as PID 1. If your tests need PID-1 init duties (signals, child reaping), they happen in your init as before.
  • The merged initrd is content-hash cached under <scratch_root>/agent-overlay-cache/<sha256>.cpio.gz. Subsequent boots of the same (initrd, overlay) pair pay nothing.

To opt out (e.g. when your initrd already bundles an agent at the path Provium would set), set inject_agent = false on the profile.

No initrd at all #

Omitting initrd means the VM has no userspace of its own: the agent overlay is the whole initramfs. Nothing is concatenated and nothing is cached — the overlay file is handed to QEMU as it stands. It carries the agent but no /init, so the agent comes up as PID 1 in its standalone mode and performs the pseudo-FS mounts itself.

That is the shape a kernel conformance suite wants, where the point is that nothing but the kernel is reachable from a test. It also saves such a project vendoring an initrd cpio it would never otherwise need.

inject_agent = false with no initrd is rejected: without injection there is no overlay to stand in for the missing file, and so nothing to boot.

disks #

[profiles.peios]
disks = [
  { path = "{out}/peios.iso", id = "medium", readonly = true },
]
TypeDefaultDescription
array of tables[]Block devices attached to the guest for the whole of every boot under this profile, in order. Each emits a -drive if=none / -device virtio-blk-pci pair, so the guest sees them as /dev/vda, /dev/vdb, … by position.

Per disk:

KeyTypeDefaultDescription
pathpath stringrequiredHost path of the backing image. Provium never creates one — a missing file fails the launch with disk \`: no image at ``. Rebased against the profile's directory and {out}-expanded like kernelandinitrd`.
idstringdisk<N> by positionThe QEMU drive id, and the name vm:disk(id) looks the attachment up under.
readonlyboolfalseAttach read-only, so a guest write is refused rather than modifying the image every later boot in the run then reads.
mediatedboolfalseServe the disk through Provium rather than handing QEMU the file, so disk:power_cut() can discard exactly what the guest never flushed. See below.

Put a disk here when the image is part of the system under test rather than part of a test. A boot medium an initramfs scans for is a sibling of initrd and root: every test boots the same one, and the profile's build already owns keeping it fresh. Put it in vm:boot({disks = …}) when it varies per test — a blank disk to install onto, a deliberately damaged filesystem.

A disk cannot come from vm:attach_disk, which runs after boot. Anything the guest's firmware or initramfs does with a block device happens long before there is an agent to call that, so a medium the boot itself depends on has to be on the bus at the guest's first instruction.

Ids are one namespace across the profile's disks and the boot's, because they end up on one QEMU command line. A duplicate within the profile is rejected when the config loads; a boot disk reusing a profile id fails the launch with disk id \` is already attached by the profile`. Profile disks are always attached first, so the medium a profile boots from keeps its device name no matter what a test adds.

Mediated disks #

A disk with mediated = true still reaches the guest as an ordinary virtio-blk device. What changes is who is underneath it: Provium serves the image over an NBD server of its own and holds each write until the guest flushes it, so disk:power_cut() drops precisely the writes that were never made durable.

Killing the VM is not a substitute for that. Unflushed data written through an ordinary drive sits in the host's page cache, which outlives the process — so a test that killed QEMU would find everything intact and report durability it had never demonstrated.

The drive is attached cache=none as a consequence rather than as a tuning choice: a read QEMU could satisfy from its own cache is a read Provium never sees.

Mediation is opt-in per disk. power_cut on a disk without it fails naming the disk, rather than quietly doing nothing.

inject_agent #

[profiles.peios]
inject_agent = false
TypeDefaultDescription
booltrueWhen true, Provium concatenates the agent overlay onto initrd at launch and appends rdinit=/sbin/provium-agent to the kernel cmdline. Set false to use the initrd as-is.

If your cmdline already pins a different rdinit=PATH, the launch errors with a clear conflict message rather than silently overriding. Either remove the conflicting rdinit=, or set inject_agent = false.

agent_overlay_path #

[profiles.peios]
agent_overlay_path = "/usr/local/share/provium/agent-overlay.cpio.gz"
TypeDefaultDescription
path stringunsetOverride the path to the agent overlay cpio. Useful for distribution-installed Provium where the overlay isn't co-located with the binary.

When unset, Provium tries (in order): the PROVIUM_OVERLAY env var, then <provium-binary-dir>/../share/provium/agent-overlay.cpio.gz, then walks up the binary's directory tree looking for dist/agent-overlay.cpio.gz (covers in-development runs from target/). Set this field — or the env var — when none of those apply.

agent_boot_timeout #

[profiles.peios]
agent_boot_timeout = 8.0
TypeDefaultDescription
float (seconds)30How long vm:boot waits for the in-VM agent to answer before declaring the boot failed. Must be positive.

Thirty seconds is a generous ceiling for a guest that is going to come up, and a long time to wait for one that is not. Lower it on a profile whose guest boots quickly and whose tests deliberately boot configurations that cannot reach an agent — an init that halts, an image with no init at all — because for those the timeout is not a safety margin but the length of every passing test. vm:boot's agent_timeout overrides it for a single boot.

Raise it instead for a guest that legitimately takes longer than thirty seconds: a large image, a slow filesystem check, a host under heavy parallel load.

cmdline #

[profiles.peios]
cmdline = "console=ttyS0 quiet"
TypeRequiredDescription
stringone of cmdline / cmdline_fileInline kernel command line. May be empty or omitted when cmdline_file is set — the two compose. A profile with neither a non-empty cmdline nor a cmdline_file is rejected with profile \` has empty cmdline and no `cmdline_file`; set at least one`.

vm:boot({kernel_cmdline = "..."}) overrides the whole command line for one boot; vm:boot({kernel_cmdline_append = "..."}) keeps it and adds tokens after it.

Provium always appends console=ttyS0 if absent. The kernel happily uses multiple console= directives, so any user-set values are preserved alongside. The reason: the QEMU command wires the serial port into Provium's console capture, so the kernel's printk needs to land there for failure diagnostics to be visible. Without console=ttyS0, PID 1's /dev/console may resolve to a device QEMU doesn't capture, and writes to stderr can fail and crash userspace.

Common additions:

  • loglevel=7 — verbose kernel logging for debugging.
  • nokaslr — for reproducible kernel addresses in panic dumps.
  • panic=1 — panic immediately rather than hanging on unrecoverable errors.

cmdline_file #

[profiles.peios]
cmdline_file = "../dist/peios-experimental-2026.8/root/usr/share/live-boot/cmdline"
cmdline      = "loglevel=7"   # optional; appended after the file
TypeRequiredDescription
path stringone of cmdline / cmdline_filePath to a file whose contents are the base command line — typically an image builder's generated cmdline. Read at VM-boot time; resolved relative to the current directory, like kernel/initrd.

All whitespace in the file — including newlines — is collapsed to single spaces, then the inline cmdline (if any) is appended after. Because the kernel applies last-wins semantics to most repeated parameters (init=, loglevel=, …), an inline token overrides the file's value for those.

The point is to stop the command line from drifting. If a builder bakes init=/usr/bin/protoinit into the image's cmdline and you hand-copy that into cmdline, the two silently diverge the next time the builder changes. Pointing cmdline_file at the builder's output means Provium reads the authoritative value every boot. See Dynamic profiles.

guest_os #

[profiles.peios]
guest_os = "peios"
TypeDefaultDescription
string"peios"Guest OS identifier. v1 only supports "peios".

Validated at config load time. A profile with guest_os = "linux" (or any other value) errors with profile \` has guest_os = `linux`, only `peios` is supported in v1`.

The field exists to future-proof the schema for other ports — when there's a Windows agent, this would be how a profile selects it.

build #

[profiles.peios]
build        = "peiso iso specs/peios.toml --out {out}"
kernel       = "{out}/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "{out}/root/system/boot/initramfs.cpio.zst"
cmdline_file = "{out}/root/usr/share/live-boot/cmdline"
TypeDefaultDescription
stringunsetShell command (run with sh -c) that produces this profile's boot artifacts. Runs once before any VM boots. A non-zero exit aborts the run.

The literal token {out} — in build and in the path fields — expands to this profile's build-output directory (see build_out), so the command's --out and the kernel/initrd/cmdline_file Provium later reads are the same path and cannot drift.

Provium tracks no staleness: the command runs every invocation. Making rebuilds cheap when nothing changed is the builder's job, not Provium's. Skip the hook with --no-build, or run it without booting via provium prepare. Full treatment on Dynamic profiles.

build_out #

[profiles.peios]
build_out = "/tmp/provium-builds/peios"
TypeDefaultDescription
path string$XDG_CACHE_HOME/provium/builds/<profile>/The directory {out} expands to.

When unset, the default base is resolved like the fixture cache — $PROVIUM_BUILD_DIR, then $XDG_CACHE_HOME/provium/builds, then ~/.cache/provium/builds, then /tmp/provium-builds — with the profile name appended. Provium creates the directory before running build but never wipes it: the build command owns its contents (so it can keep its own incremental-build caches there).

Multiple profiles #

You can declare any number of [profiles.<name>] blocks; tests pick one by name per VM. Patterns for multi-profile setups (debug builds, cross-version testing, feature-flag gating) are on Profiles.

from_dir #

[profiles]
from_dir = "profiles"

Reads profiles from a directory instead of writing them inline. Each subdirectory holding a profile.provium.toml is one profile, named after the directory:

provium.toml
profiles/
  kernel-only/
    profile.provium.toml
  full-system/
    profile.provium.toml

gives the profiles kernel-only and full-system. A subdirectory without that file is not a profile and is skipped rather than rejected, so a profile directory may keep whatever else it needs beside its config. A from_dir that names a directory holding no profile at all is an error.

The file's contents are the fields above — everything from [profiles.<name>] except the name, which the directory supplies.

Adding a testset becomes mkdir, with nothing central to keep in sync. That matters most for a repository whose whole purpose is holding many of them.

from_dir and inline blocks may both appear. A discovered profile whose name collides with an inline one is an error, not a silent winner.

What a discovered profile's paths are relative to #

Its own directory — both the paths it names and the directory its build runs in. An inline profile keeps resolving against Provium's current directory, as it always has.

This is the rule that makes a profile directory self-contained: it can be moved, copied or renamed and still mean what it says. It also means the same relative path means the same thing whether it is written in the shared config or in a profile beside it, which is not true if everything resolves against one directory.

{out} paths are exempt, since a build-output directory is absolute and has nothing to do with where the profile lives.

# profiles/kernel-only/profile.provium.toml
#
# Both specs are named relative to this directory, and the command runs
# here, so `../../peiso.toml` is the repository's.
# `--out` must not already exist, and Provium never wipes {out} —
# clearing it is the build's own job.
build   = "rm -rf {out}/root && peiso root ../../peiso.toml peiso.toml --out {out}/root"
root    = "{out}/root"
cmdline = "console=hvc0 quiet panic=1"

Important: the fixture cache key folds in EVERY profile's kernel + initrd identifier. Adding a new profile invalidates the entire fixture cache. This is intentional: a new kernel could behave differently, so existing fixtures are no longer trustworthy. See Profiles — What happens to the cache when profiles change.

Configuration loading #

StepWhat happens
1. Readprovium.toml is read from --config <path> (default ./provium.toml).
2. ParseTOML is parsed. Parse errors include the file path.
3. ValidateEach profile is validated (a non-empty cmdline or a cmdline_file; guest_os = "peios").
4. ExpandThe {out} token in each profile's build command and path fields is replaced with that profile's resolved build-output directory.
5. UseThe Config struct is wrapped in an Arc and passed to every file runner.

Errors:

ErrorCause
read \`: `File missing or unreadable.
parse \`: `Malformed TOML.
invalid config in \`: `Validation failed (empty cmdline, unsupported guest_os, etc.).

All three abort the run with exit code 2.

Worked example #

[provium]
roots          = ["tests", "internal-tests"]
cache_dir      = "/srv/provium-cache"
cache_max_size = "200G"

[profiles.peios]
kernel   = "/srv/peios-builds/latest/bzImage"
initrd   = "/srv/peios-builds/latest/initrd.cpio.gz"
cmdline  = "console=ttyS0 quiet panic=1"
guest_os = "peios"

[profiles.peios-mainline]
kernel   = "/srv/peios-builds/mainline/bzImage"
initrd   = "/srv/peios-builds/mainline/initrd.cpio.gz"
cmdline  = "console=ttyS0 quiet panic=1"
guest_os = "peios"

[profiles.peios-debug]
kernel   = "/srv/peios-builds/debug/bzImage"
initrd   = "/srv/peios-builds/debug/initrd.cpio.gz"
cmdline  = "console=ttyS0 debug loglevel=7 nokaslr panic=1"
guest_os = "peios"

This declares three profiles (production, mainline, debug), centralises the cache on a 200 GiB dedicated mount, and scans both tests/ and internal-tests/.

See also #

  • Project structure — the broader project layout.
  • VM reference — boot_opts.kernel_cmdline overrides and kernel_cmdline_append.
  • Profiles — patterns for using multiple profiles.

Profiles

Provium / Configuration

A profile names what a VM boots — a kernel, an initramfs, a command line, a guest OS — in provium.toml. Tests pick a profile by name when they create a VM. This page covers the practical patterns.

The configuration field reference is on provium.toml. A profile can also build its own artifacts before booting rather than pointing at files already on disk — see Dynamic profiles.

When you need more than one profile #

For most projects, one profile is enough — peios, pointing at the latest build. You'd add more when:

  • You're testing across kernel versions (peios-stable, peios-mainline).
  • You want a debug build available for diagnosing failures (peios-debug).
  • You're gating tests on a feature flag in the kernel (peios-prefeatx, peios-postfeatx).
  • You're testing a port to a new architecture (peios-arm64).

Declaring multiple profiles #

[profiles.peios]
kernel   = "/build/peios/bzImage"
initrd   = "/build/peios/initrd.cpio.gz"
cmdline  = "console=ttyS0 quiet"

[profiles.peios-debug]
kernel   = "/build/peios-debug/bzImage"
initrd   = "/build/peios-debug/initrd.cpio.gz"
cmdline  = "console=ttyS0 debug loglevel=7 nokaslr"

[profiles.peios-stable]
kernel   = "/build/peios-stable/bzImage"
initrd   = "/build/peios-stable/initrd.cpio.gz"
cmdline  = "console=ttyS0 quiet"

Tests pick:

provium:vm("v",        "peios")
provium:vm("debug-v",  "peios-debug")
provium:vm("stable-v", "peios-stable")

A test can mix profiles in the same file — useful for compatibility testing:

test("stable client can talk to mainline server", function(t)
    local lan    = provium:bridge("lan")
    local server = provium:vm("server", "peios"):boot()
    local client = provium:vm("client", "peios-stable"):boot()
    lan:attach({server, client})
    server:run("ip addr add 10.0.0.1/24 dev eth0 && ip link set eth0 up")
    client:run("ip addr add 10.0.0.2/24 dev eth0 && ip link set eth0 up")

    -- Run a real protocol against the mismatched pair.
    server:run_async("python3", {args = {"-m", "http.server", "8080"}})
    wait_until(function()
        return server:run("ss -ltn | grep :8080"):ok()
    end, {timeout = "5s"})
    client:run("curl -s http://10.0.0.1:8080/"):assert_ok()
end)

One directory per profile #

Profiles do not have to be written in provium.toml at all. [profiles] from_dir reads them from a directory, one subdirectory each:

[provium]
roots = ["tests"]

[profiles]
from_dir = "profiles"
profiles/
  kernel-only/
    profile.provium.toml     # the profile
    peiso.toml               # whatever else it needs, beside it
  full-system/
    profile.provium.toml
    peiso.toml

Everything a discovered profile names is relative to its own directory, and its build runs there. So a profile directory is self-contained: it can be moved, copied or renamed without rewriting what is inside it, and adding one is mkdir rather than an edit to a central list.

Prefer this when profiles carry files of their own — an image spec, a package manifest, a vendored initrd — and especially when the repository's purpose is holding many of them. Prefer inline blocks when a profile is three lines pointing at paths that already exist.

Profile choice patterns #

Default vs debug #

The most common split: a fast peios build for the default loop, and a peios-debug build with loglevel=7, nokaslr, and (probably) KASAN/UBSAN enabled. Use the debug profile when reproducing a kernel bug:

test("debug build reveals the leak", {tags = {"slow", "debug"}}, function(t)
    local vm = provium:vm("v", "peios-debug"):boot()
    vm:run("…workload that leaks…")
    local r = vm:run("dmesg | grep -i 'BUG:'")
    -- KASAN / UBSAN reports show up here in the debug build.
end)

Cross-version compatibility #

Two profiles, one per release line. Ship a small handful of cross-version tests:

test("rolling upgrade: stable client + mainline server", function(t)
    -- … as above …
end)

test("rolling upgrade: mainline client + stable server", function(t)
    -- … inverse …
end)

Tag these cross-version and gate via --no-tag cross-version in the dev loop. CI runs them on a nightly cadence with --include-slow.

Booting only a kernel #

A profile that names no initrd boots the agent overlay alone: the agent is PID 1 and there is no userspace at all. Paired with a root composed to hold nothing but the kernel, that gives a VM in which the only thing a test can reach is the kernel — so a failure is attributable to it and not to something in userspace that happened to be installed.

# profiles/kernel-only/profile.provium.toml
build   = "./build.sh {out}"
root    = "{out}/root"
cmdline = "console=hvc0 quiet panic=1"

The build is a script beside the profile rather than a peiso root one-liner because Provium runs it on every invocation: --out must not already exist, Provium never wipes {out}, and composing unconditionally can cost far more than the tests do.

No kernel either: Provium finds it inside the composed root. Tests then drive the kernel directly, through the agent's syscall interface rather than through any command:

test("stratafs refuses a duplicate stratum", function(t)
    local vm = provium:vm("v", "kernel-only"):boot()
    -- … mount(2) via the agent, assert on errno …
end)

Feature-flag gating #

Two profiles built from the same source with one feature toggled:

[profiles.peios-prefeatx]
kernel  = "/build/peios-prefeatx/bzImage"   # FEATX disabled
# …

[profiles.peios-postfeatx]
kernel  = "/build/peios-postfeatx/bzImage"  # FEATX enabled
# …

Tests that exercise FEATX-specific behaviour pick peios-postfeatx; regression tests run against both.

test("featx changes API behaviour", function(t)
    local before = provium:vm("v1", "peios-prefeatx"):boot()
    local after  = provium:vm("v2", "peios-postfeatx"):boot()

    local r1 = before:run("…")
    local r2 = after:run("…")
    t:assert_neq(r1.stdout, r2.stdout)
end)

Per-VM cmdline overrides #

The profile's cmdline is the default; kernel_cmdline in vm:boot(opts) overrides it for one VM:

local vm = provium:vm("v", "peios")
vm:boot({kernel_cmdline = "console=ttyS0 quiet maxcpus=1 isolcpus=0"})

This is useful for one-off testing of cmdline-sensitive features. For cmdline configurations you use repeatedly, declare a dedicated profile.

To add a parameter rather than restate the whole line, kernel_cmdline_append keeps the profile's tokens and puts yours after them:

vm:boot({kernel_cmdline_append = "maxcpus=1"})

Where an appended token repeats one the profile already sets, the kernel takes the last occurrence, so the appended value wins.

Default profile selection #

One subcommand takes an optional profile arg and falls back to "the first profile in provium.toml sorted by name" when you omit it:

SubcommandBehaviour
provium repl --fixture <path> (no profile)Boots the first profile by sorted name.

That is a boot-time choice and nothing more. It does not narrow the fixture cache key: the test runner, provium fixture build, provium fixture rebuild, provium fixture stale and provium repl --fixture all fold every profile's kernel and initrd into the key, so they address the same cache entry however many profiles you have configured. Pre-warming in CI with provium fixture build warms the entry the runner then reads.

To make the REPL default predictable, name your most common profile so it sorts first alphabetically. aaa-default is ugly but it works; peios is fine when it's your only profile.

What happens to the cache when profiles change #

The fixture cache key folds in the kernel and initrd identifiers of every profile in provium.toml, sorted by name for determinism (the full key model is on fixtures and dependencies). Practical consequences for profile changes specifically:

ActionCache effect
Edit a kernel image (any profile)Every fixture invalidates.
Edit an initrd image (any profile)Every fixture invalidates.
Add a new profileEvery fixture invalidates (the new profile's kernel/initrd are folded in).
Remove a profileEvery fixture invalidates.
Rename a profileUsually invalidates everything (the fold order follows profile names).
Edit cmdline on a profileCache is unaffected (cmdline isn't in the key).
Change cache_dirCache is unaffected — the new dir is just empty.

This is deliberately conservative. A new profile means a new kernel could behave differently, so the existing fixtures might be subtly stale. Rather than guess, the harness rebuilds.

If you need to add a profile without invalidating the cache, you can't. Either accept the rebuild or use a sibling provium.toml with --config alt-config.toml.

Multi-arch profiles (preview) #

In v1, the QemuVmm backend invokes qemu-system-x86_64 exclusively — there's no per-profile arch field yet. To run on aarch64, you'd have to swap the binary at the harness level (not currently exposed).

When multi-arch lands, the profile is the natural place for an arch = "x86_64" / arch = "aarch64" field.

See also #

  • provium.toml reference — every field.
  • VMs and profiles — provium:vm(name, profile, opts?).
  • Fixtures and dependencies — what triggers a fixture rebuild.

Dynamic profiles

Provium / Configuration

A static profile points at artifacts that already exist on disk:

[profiles.peios]
kernel  = "/build/peios/bzImage"
initrd  = "/build/peios/initrd.cpio.gz"
cmdline = "console=ttyS0 quiet"

That leaves a gap. You build the image with one tool, then run Provium with another, and nothing connects the two — so it's easy to test last week's image because you forgot to rebuild. A dynamic profile closes the gap: the profile owns a build command, Provium runs it, and the VM boots the fresh result.

This is the same idea as cmdline_file — let the test consume the authoritative build instead of a hand-copied snapshot — applied to the whole image. This page covers both.

Cmdline from the builder #

Start with the smaller case. Image builders usually emit a kernel command line of their own — a peiso build, for instance, carries the one its boot package ships at root/usr/share/live-boot/cmdline. If you copy that string into your profile's cmdline, the two drift the moment the builder changes it (a new init=, a new console). Point cmdline_file at the builder's file instead:

[profiles.peios]
kernel       = "../dist/peios-experimental-2026.8/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "../dist/peios-experimental-2026.8/root/system/boot/initramfs.cpio.zst"
cmdline_file = "../dist/peios-experimental-2026.8/root/usr/share/live-boot/cmdline"

Provium reads the file at boot, collapses its whitespace to single spaces, and uses it as the command line. You can still add tokens inline — they're appended after the file, so they win for last-wins kernel parameters:

cmdline_file = "../dist/peios-experimental-2026.8/root/usr/share/live-boot/cmdline"
cmdline      = "loglevel=7"     # verbose, on top of whatever the builder set

No manual copy means nothing to forget to update.

Building the whole image #

The build field takes a shell command. Provium runs it — once, before any VM boots — and only then reads the profile's kernel / initrd / cmdline_file:

[profiles.peios-full]
build        = "peiso iso specs/peios-full.toml --out {out}"
kernel       = "{out}/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "{out}/root/system/boot/initramfs.cpio.zst"
cmdline_file = "{out}/root/usr/share/live-boot/cmdline"

Now provium builds peios-full before the suite runs, and tests boot exactly what was just built:

local vm = provium:vm("v", "peios-full"):boot()

The command runs with sh -c, inheriting your environment and streaming its output straight to the terminal. It runs from Provium's working directory — or, for a profile discovered via from_dir, from that profile's own directory. Either way, reference inputs (the manifest above) relative to that directory, so the suite stays portable across checkouts; there is deliberately no "build working directory" knob to tie it to one machine's layout.

{out}: one directory, no drift #

Look again at the example: {out} appears in the build command's --out and in every path field. That's the point. {out} expands — once, when the config loads — to this profile's build-output directory, so the place the build writes and the places Provium reads are guaranteed to be the same directory. You can't update one and forget the other, because there's only one.

Where does {out} point? By default, a per-profile directory under the Provium cache:

$XDG_CACHE_HOME/provium/builds/<profile>/     # e.g. ~/.cache/provium/builds/peios-full/

Set build_out to pin it somewhere specific — but you still only write the path once:

[profiles.peios-full]
build_out    = "/tmp/provium-builds/peios-full"
build        = "peiso iso specs/peios-full.toml --out {out}"
kernel       = "{out}/root/usr/lib/modules/<release>/vmlinuz-<release>"
# …

Provium creates the directory before running the build, but never wipes it — the build command owns its contents, so it's free to keep its own incremental-build caches there.

When the build runs #

You runWhat gets built
provium (the test suite)Every profile that declares a build, up front, before the scheduler starts.
provium console <profile>Just that profile.
provium repl <profile>Just that profile.
provium prepare [profile]That profile, or — with no argument — every profile with a build. No VM boots.

The test runner builds all dynamic profiles rather than only the ones a run will touch, because tests choose their profiles at runtime from Lua (provium:vm(name, profile)) — Provium can't know in advance which a given run needs. If you keep several dynamic profiles and want to build only one, use provium prepare <profile> followed by provium --no-build.

Two controls shape this:

  • provium prepare builds without booting. It even skips the pre-flight checks (/dev/kvm, networking, CAP_NET_ADMIN), so you can build an image on a machine that isn't set up to run VMs.
  • provium --no-build skips the hook entirely for one run — for when you know the artifacts are already current and just want to boot.

No staleness — that's the builder's job #

Provium does not try to decide whether your image is up to date. It runs the build command every invocation and trusts the builder to be cheap when nothing has changed. Tracking inputs, hashing sources, and skipping unnecessary work is what build tools are for; reimplementing that inside a test harness would only get it subtly wrong.

The practical consequence: if your builder does a full rebuild every time, provium pays that cost every time. That's usually fine — Provium tends to be the last gate you run, where a clean build is what you want anyway — but if it bites, make the builder incremental (or use --no-build between source changes). Don't expect Provium to shortcut it.

Making a builder incremental #

Some builders have no incremental mode to reach for. peiso root is one: it composes from scratch and refuses a --out that already exists, so the obvious command has to clear its own output first —

build = "rm -rf {out}/root && peiso root shared.toml peiso.toml --out {out}/root"

— and that is a full compose on every provium run, however small the change. Where the tests themselves take a second, the build can be most of a minute of it.

The fix is a script beside the profile that decides for itself:

#!/bin/sh
# ./build.sh {out}
set -eu
out="$1"; root="$out/root"; stamp="$out/.build-stamp"

# Whatever the composition depends on. Name, size and mtime rather than
# content: a rebuilt package always moves one of them, and hashing a
# kernel package on every test run would defeat the point.
fingerprint() { cat shared.toml peiso.toml; ls -lL ../../pkgs/out/; }

new=$(fingerprint | sha256sum)
[ -d "$root" ] && [ "$new" = "$(cat "$stamp" 2>/dev/null)" ] && exit 0

rm -f "$stamp"          # an interrupted compose must not leave a stamp
rm -rf "$root"          # claiming the tree beside it is current
peiso root shared.toml peiso.toml --out "$root"
printf '%s' "$new" > "$stamp"
build = "./build.sh {out}"

A discovered profile runs its build in its own directory, so ./build.sh and the paths inside it mean what they say where they are written.

Two details matter. Remove the stamp before the work rather than only writing it after, so a compose interrupted halfway is not mistaken for a current one on the next run. And keep the fingerprint cheap enough to run unconditionally — it is paid on every invocation, including the ones that do nothing.

Fixtures rebuild when the image changes #

Provium's fixture cache keys each snapshot partly on the kernel and initrd's path, size, and modification time. A dynamic profile rebuilds its image every run, which usually changes those timestamps — so fixtures captured against the old image are treated as stale and rebuilt. That's correct (a freshly built kernel can behave differently), but it does mean a dynamic profile plus a large fixture tree pays a fixture rebuild each run on top of the image build. If that's too slow for the inner loop, --no-build keeps the image — and therefore its fixtures — warm between source changes.

Failures abort — never fall through #

A build command that exits non-zero stops the run:

provium: building profile `peios-full` → /home/you/.cache/provium/builds/peios-full
… builder output …
provium: profile `peios-full`: build command failed (exit status: 1)

This matters because a half-finished build often leaves the previous run's artifacts in place — they'd pass an existence check and boot cleanly, and you'd be testing a stale image while believing the build succeeded. Provium refuses to do that: a failed build is a failed run (exit code 2), full stop.

Worked example #

A self-contained suite that builds its own image. The peiso spec lives in the suite, so a fresh clone can provium with nothing else set up:

# my-suite/provium.toml
[provium]
roots = ["tests"]

[profiles.peios-full]
build        = "peiso iso specs/peios-full.toml --out {out}"
kernel       = "{out}/root/usr/lib/modules/<release>/vmlinuz-<release>"
initrd       = "{out}/root/system/boot/initramfs.cpio.zst"
cmdline_file = "{out}/root/usr/share/live-boot/cmdline"
my-suite/
├── provium.toml
├── specs/
│   └── peios-full.toml        # the image spec, versioned with the tests
└── tests/
    └── smoke.test.lua

Day-to-day:

provium                       # build the image, then run the whole suite
provium --no-build            # skip the build; boot the last-built image
provium prepare               # build the image, don't run anything
provium console peios-full    # build, then drop to the guest's console

See also #

  • provium.toml reference — the build, build_out, and cmdline_file fields.
  • Profiles — patterns for static and multi-profile setups.
  • CLI reference — provium prepare and --no-build.

Peios Learn — documentation for the Peios project.

Built with Trail.