An ephemeral test-VM harness for the pre-push loop.
reaper spins up a disposable virtual machine, syncs your uncommitted working
tree into it, runs your project's end-to-end battery there, rolls the guest's
state back between iterations via in-guest ZFS, and tears the machine down. A
fully independent sweep destroys any machine whose time-to-live has expired, so
no failure mode leaks VMs.
The purpose is the tight edit -> test cycle before commit and push. It is
deliberately not a CI platform. CI remains the independent re-proof after
you push, and nothing here replaces it.
Status: Phase 5. A session can be brought up, listed, renewed and taken down; two guest templates exist and are proven; a working tree can be synced in, built, run, and its results collected back out; and tenant state rolls back in under three seconds. See
docs/STATUS.mdfor where the work actually stands, which is the document to trust over this one when they disagree.
Fixed, because the three components are easy to conflate:
| Term | Meaning |
|---|---|
| reaper | The framework, and the client CLI you run in your project |
| runner | The agent baked into a guest template, which executes your verbs |
| cull | The independent TTL sweep, deployed on its own machine |
| tenant | A project consuming the framework. Its integration surface is one manifest file |
| guest | A blessed VM template a tenant can run on |
| provider | The hypervisor plugin that creates and destroys machines |
Three components, strictly layered; each knows less than the one above it.
The CLI runs on your workstation, in your project's checkout. It reads
.reaper.toml, talks to a provider, and talks to the runner over SSH. It owns
session lifecycle, working-tree sync (which must handle uncommitted trees --
committing to run your tests is not a thing this asks of you), heartbeat renewal
of the expiry tag, and pulling result artifacts back into your tree.
The runner lives inside the guest. It creates the storage pool on first
boot, lays out datasets, executes your manifest's verbs, takes the @pristine
snapshot after the first successful stack-up, and performs reset as stop the
stack, roll back, let the next run restart it.
The cull runs on a separate machine with a separate credential. It knows only tags and time. It is the backstop, not the janitor for routine exits.
tank/images container image store -- never rolled back
tank/cache per-ecosystem build caches -- never rolled back
tank/state your project's state -- rolled back freely
tank/work synced tree + results out -- never rolled back
reaper hosts any program with an end-to-end battery. It does not decline on
the grounds of what your program is, what it is written in, or what it runs on.
That resolves into three independent seams, which fail differently and are
therefore kept apart:
Tenant. No tenant name appears anywhere in framework code. The framework
never learns tenant vocabulary -- it does not know what a "stage", "journey",
"seed" or "shrinker" is, and your run.cmd is an opaque string handed to a
shell. A lint guard enforces this; good intentions do not.
Guest OS. Two different people make two different choices here, and
conflating them is exactly how an OS gets hardcoded. The sysadmin decides
which guests exist, in a site registry that lives outside this repository.
The developer declares which guests their project wants, in the manifest, and
may name several. Adding an operating system is a registry edit and a template
build, never a code change. See docs/guests.md.
Hypervisor. Machine creation sits behind a provider trait. One
implementation ships. The door is left open a crack for a second, and no wider:
no dynamic loading, no ABI, no plugin boundary. A new provider is a new module
compiled in. See docs/providers.md.
What reaper is not, and what the answer is when a feature request implies
one of these -- a tenant-side change, or a plain "no":
- Not a test framework. Journeys, oracles, seeds and shrinkers are your
code, invoked opaquely through
run.cmd. - Not a scheduler. No queueing, no multi-node placement, no fair sharing.
- Not CI. No pipeline integration, no build artifacts, no verdict publishing.
- Not a secrets manager. The scope is "where do the credentials live", and no further.
- Not a media manager. reaper never fetches an installation image, and never uploads one. Media is expected to be on the provider already; a missing ISO is a request to whoever administers the cluster, exactly like a missing template.
The original plan ships verbatim as docs/reaper-plan.md
because it is a record. Three of its constraints have since been deliberately
lifted, and the plan text still carries the old wording. Rather than edit a
record, the overrides are named here:
| The plan says | What is true now |
|---|---|
| §4: "No support for guests other than the one blessed template" | Multiple guests are supported. Which ones exist is the sysadmin's registry, not the framework's business |
| §3: "No language toolchains in the template, ever" | Holds for container-execution templates, where toolchains arrive as pinned images. A host-execution template carries its toolchain by design |
| §5: "The PVE API is the only hypervisor interface" | True of the Proxmox provider specifically. The core talks to a provider trait |
A session is expensive: it is a whole virtual machine, and starting one costs minutes. It earns that cost for the tiers that need a real stack -- full-stack containerized suites, simulated-user runs over accumulated history, and live browser audits.
The cheap tiers -- unit tests, contract tests, browser-against-a-fake -- stay on your workstation, where they belong. Migrating them into sessions because sessions exist is an anti-pattern: it slows the fast loop and buys nothing, since none of those tiers can see a defect that needs a real machine to surface.
docs/testing-methodology.md sets out the full
portfolio and what question each tier uniquely answers.
Three audiences, in the order they appear: whoever administers the cluster, whoever onboards a project, and whoever runs the loop all day.
cargo build --release # target/release/reaperTagging v<version> builds it for x86-64 and arm64 Linux and x86-64 and arm64
FreeBSD, and uploads each binary to Bitbucket Downloads beside a .sha256 file
holding its checksum. The Linux binaries are built against Debian 12's glibc
rather than whatever the newest base image carries, because the platform is
forward compatible and not backward compatible, so building on the newest thing
available produces a binary that will not start on anything else. Each is a
single self-contained file: the runner is compiled in, so there is nothing to
unpack and nothing to install beside it. There is no Windows build, and that is
a property of the program rather than of the pipeline -- see the header of
ci/build-target.sh.
Nothing is installed into a guest. The runner is compiled into the binary and delivered over SSH on every operation, so upgrading reaper never means rebuilding a template.
Building on FreeBSD or another BSD? Build natively.
rustlsis mandatory andnative-tlsis banned -- an OpenSSL linkage difference between build hosts is exactly the kind of portability failure that shows up late and confusingly, and a pure-Rust TLS stack is the reason this project has no such difference to have.That ban is also what makes the released FreeBSD binaries possible at all. Bitbucket Pipelines runs Linux containers and cannot host a VM, so those two targets are cross-linked with
cargo-zigbuildrather than built on FreeBSD. Nothing in the dependency graph links a system library, which is what keeps that honest -- but it is a weaker claim than a native build, and a released FreeBSD binary is worth smoke-testing on the oldest release it targets before it is announced.
Build a guest template and register it. Follow
docs/runbooks/ exactly; both runbooks record defects found
the hard way, and both templates here shipped subtly broken at least once. The
guest contract is docs/guests.md.
Write the site registry at ~/.config/reaper/config.toml
($XDG_CONFIG_HOME/reaper/, /etc/reaper/, or REAPER_CONFIG to be explicit):
provider = "proxmox"
[session]
default_ttl = "2h" # how long a session lives without a heartbeat
heartbeat_interval = "5m" # must fit at least three times into the TTL
ready_grace = "30m" # the first expiry, covering a slow clone
max_concurrent = 2 # across the provider, not per workstation
default_disk_gb = 64
ssh_key = "~/.config/reaper/session-key"
[guests."ubuntu-26.04"] # the name tenants ask for
template = "9001" # opaque; the provider reads it
[proxmox]
api = "https://node.example:8006"
node = "somenode"
pool = "a/pool"
id_range = [9000, 9099]
token_file = "~/.config/reaper/token"
data_storage = "some-storage" # where each session's blank pool disk is made
min_free_gb = 10 # room to leave after a session takes its share
tls = "ca-file" # webpki | ca-file | insecure
ca_file = "~/.config/reaper/node-ca.pem"data_storage is not optional in practice: without it, the first up that
asks for a session disk is refused, and every session asks for one.
The credential is one line, user@realm!name=secret, in a file only you can
read. Full reference: docs/site-config.md.
Deploy the sweeper from cull/ on a different machine with its
own credential. This is the backstop that destroys anything whose expiry has
passed, and it is what makes every other failure mode survivable. Run it from
cron; --dry-run first. Read cull/README.md before you do:
it is short, and it is the only place the deployment itself is written down --
docs/site-config.md says why that credential is
separate, not what the file looks like. Two things in it will otherwise cost
you an evening:
- The credential is not the same shape as the CLI's. The sweeper sources
a file that sets
PVE_TOKENto the entire authorization header value; the CLI reads a file containing justuser@realm!name=secret. They live on different machines and neither should learn about the other, which is exactly why nothing warns you when you write one and expect the other. - The cron interval and
sweep_withinare the same decision.doctorreports the sweeper absent when something has been expired for longer thansweep_within(docs/site-config.md, default 15m), so a sweeper on a slower schedule than that is a healthy site that reports itself broken. Set the interval comfortably under it.
Nothing here deploys it, and nothing here should: adopting a new version means a person copying it across and reading the diff first. A backstop that shared a deployment path with the thing it backs up would fail at the same moment.
Write .reaper.toml at its root. That is the entire integration surface -- no
plugin, no callback, no framework edit:
schema = 1
project = "my-project"
guests = ["ubuntu-26.04"] # what the sysadmin registered
exec = "container" # or: "host"
[build]
image = "docker.io/library/rust@sha256:3382bd…" # digest, never a tag
cmd = "cargo build --locked --tests"
cache = ["cargo", "target"]
[run]
cmd = "cargo test --workspace"
images = [] # pre-pulled for you if you list any
[sync]
exclude = ["/target/"] # rsync patterns; out/ is always excluded
[reset]
datasets = ["state"]
[resources]
cores = 4
ram_gb = 8Check it before you need it:
reaper-manifest-validate .reaper.tomlThree things to get right, because they are where projects actually stumble:
- State must live under
$REAPER_STATE.resetrolls back that dataset and nothing else. A database whose data directory sits inside its container has no state reaper can roll back, and reset will appear to do nothing. execis per verb. A build often wants a pinned toolchain image while the run needs the guest's own container engine -- a toolchain image has no engine client in it. Sayrun: { exec: host }when that is the shape.- Images are digest-pinned. Tags are refused outright, including
repo:tag@sha256:…, where the tag is unverified and can drift away from the digest while looking checked.
Worked examples: manifest/examples/. Full contract:
docs/tenants.md.
reaper up # a machine of your own, images already fetched
reaper test # sync -> build -> reset -> run
reaper down # gone, and gone anyway if you vanishEach step is also a verb of its own, for when you want one without the others:
reaper sync |
your uncommitted tree in, results back out |
reaper build |
your build command, in your pinned toolchain |
reaper run |
your run command; traces arrive while it runs |
reaper reset [--to NAME] |
state back to a known point, in seconds |
reaper test --to NAME |
the loop, resetting to a named point rather than pristine |
reaper snapshot NAME |
name a point to come back to |
reaper list |
what is up, how long it has left, whether its heartbeat is alive |
reaper renew [--ttl 4h] |
more time |
Useful flags: --profile nightly picks a profile (its TTL, its environment, and
whether caches are warm); --manifest path points at a project other than the
current directory; most verbs take a session name to act on just one.
Results come back while a run is happening, not at the end -- every few
seconds into out/ in your tree, again when the command stops whether it passed
or failed, and once more on down. A failure trace should never exist only on a
machine scheduled for destruction.
The first test on a session skips the reset, because there is nothing to
reset to yet; that run takes @pristine and every later test gets all four
steps. A run that fails takes no snapshot, so a session whose first run never
succeeded keeps skipping until one does.
Resetting from inside your own stack, for a driver that wants a clean slate between passes without knowing ZFS exists:
"$REAPER_CONTROL/reset" # roll back; blocks until it is done
"$REAPER_CONTROL/snapshot" # or mark *here*, once your stack is upThe container that asks is spared when the others are stopped. snapshot is how
you get a pristine taken at stack-up rather than at the end of a run.
reaper doctor # config, key, provider, templates, storage, records
reaper doctor --canary # additionally prove the sweeper with a real machineEvery check runs and reports -- knowing three things broke beats knowing one did. Exit 0 is healthy (warnings permitted), 1 means something failed, 2 means doctor itself could not run. The warnings are honest: an unexercised sweeper reads WARN "no evidence either way", never a false ok.
| What you see | What it means |
|---|---|
no guest named "x" is registered here |
A typo, or a template nobody has built. Costs nothing -- it is refused before anything is created |
waiting -- <v6 address>: No route to host |
Normal. A dual-stacked guest reports IPv6 before its DHCP lease; reaper keeps trying until something answers |
created, but nothing answered on it within 30m |
The machine exists and carries an expiry, so nothing is leaked. reaper list shows it; reaper down removes it |
<pid> DEAD in reaper list |
The heartbeat stopped, so the expiry stopped moving. Nothing is leaked -- the sweeper will collect it -- but you are on a countdown nobody is winding |
refusing to roll … back: process(es) N still have files open |
Something is still using the dataset. Rolling back under it would leave it reading data that no longer exists |
there is no tank/state@pristine to roll back to |
No run has succeeded on this session yet |
… has N free and this session needs M, leaving less than the … floor |
The storage would be too full. Take a session down, or lower min_free_gb if you mean to run it close |
N session(s) are already up on this provider … not yours |
The cap counts the whole cluster, so somebody else's sessions count against it |
could not pre-fetch images |
A registry blip. The session is up and usable; the first build fetches them itself |
a suite that failed but reaper test exited 0 |
Your cmd pipes, and /bin/sh is dash, which has no pipefail. See docs/tenants.md — this one also poisons @pristine |
there is no "x" to reset to from test --to x |
A name that does not exist is an error, not a skip. Likely a typo |
WARNING: TLS certificate verification is disabled |
Exactly what it says. Export the node's CA and switch tls to ca-file |
If a session is unreachable and you want to know why, its console is readable
through the API -- see
providers/proxmox/tools/console.mjs.
Note the limitation it documents: an API token cannot open a console, so it
needs a user login.
The .reaper.toml at the root is real, not an example. reaper test here runs
reaper's whole battery -- Rust suites, shell suites and seam guards -- inside a
session. It has already caught three portability bugs that a workstation-only
run could not, and that is the reason it exists.
MIT. See LICENSE. Copyright (c) 2026 Axonibyte Innovations, LLC.