Bento is a self-hosted platform for ephemeral and persistent Linux instances.
  • Rust 94.7%
  • TypeScript 4.9%
  • CSS 0.3%
Find a file
Riley Loo 7fedad1b76 test: drive the bootc conversion path end to end
The bootc work had unit coverage over injected runners, but nothing ran
the shipped binary through a conversion. This adds that tier for OCI
images, alongside the download path already there.

A fourth substitute joins libvirtd, the image mirror, and `nft`: a
`podman` stub on PATH. A real build pulls gigabytes, runs a privileged
container, and writes to the rootful container store, so the stub records
each command line and, for the image-builder step, writes a real qcow2
through `qemu-img`. Its content follows the `--bootc-ref` it was given,
because two operating-system images must not collapse onto one
content-addressed disk. Everything else is the shipped code path: the
configuration, the allowlist sync, `fetch-images`, the API, and the
overlay create that would reject a disk that is not an image.

The OCI entry is opt-in, through a new `Setup` argument, because an OCI
image in the allowlist makes Podman a fatal host requirement (SPEC 4.2)
and the other five tests should not pay for it. The configuration always
carries a `[bootc]` section with `rootfs = "xfs"` — deliberately not the
`ext4` default, so a test can tell a configured value from a fallback.

Two cases:

  * `a_bootc_image_is_converted_to_a_disk_an_instance_boots_from` checks
    the pipeline order, that the contract check runs unprivileged and
    without a network before the image reaches the privileged builder,
    that the build carries the pinned builder digest, both mounts, and
    the configured filesystem, that the entry comes back as `oci` with a
    stored version, that an instance created from it reaches running with
    the converted disk as its overlay's backing file, and that a second
    `fetch-images` re-checks the moving tag but does not rebuild the same
    source digest.
  * `an_operator_adds_a_bootc_image_at_runtime` checks that POST
    /api/images appends to the durable allowlist, builds before it
    answers, gives the new image its own disk, and refuses a request with
    no credential.

Both were checked against mutations of the code under test: dropping the
configured rootfs in `setup.rs` and disabling the source-digest cache in
`fetch.rs` each fail with the recorded command lines in the message.

TESTING.md gains the new substitute and states the new boundary. No real
bootc image is converted here, so whether an image satisfies the contract
and whether its disk boots still needs a machine with Podman.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 00:03:39 -07:00
.github/workflows test: end-to-end suite driving the real binary, on x86_64 and arm64 2026-08-25 23:44:20 -07:00
bentod test: drive the bootc conversion path end to end 2026-08-26 00:03:39 -07:00
branding web: use the real logo instead of the bento-box emoji (fixes #5) 2026-08-23 20:50:16 -07:00
crates images: address bootc review findings 2026-08-26 00:03:39 -07:00
web images: address bootc review findings 2026-08-26 00:03:39 -07:00
.gitignore update gitignore 2026-08-22 21:59:50 -07:00
bento.example.toml images: address bootc review findings 2026-08-26 00:03:39 -07:00
Cargo.lock test: end-to-end suite driving the real binary, on x86_64 and arm64 2026-08-25 23:44:20 -07:00
Cargo.toml rust: scaffold the Cargo workspace, port types and config 2026-08-14 20:30:35 -07:00
DEPLOYING.md images: address bootc review findings 2026-08-26 00:03:39 -07:00
LICENSE Initial commit 2026-08-09 22:09:18 -07:00
Makefile test: end-to-end suite driving the real binary, on x86_64 and arm64 2026-08-25 23:44:20 -07:00
README.md images: address bootc review findings 2026-08-26 00:03:39 -07:00
rust-toolchain.toml rust: scaffold the Cargo workspace, port types and config 2026-08-14 20:30:35 -07:00
SPEC.md images: address bootc review findings 2026-08-26 00:03:39 -07:00
TESTING.md test: drive the bootc conversion path end to end 2026-08-26 00:03:39 -07:00

Bento

Bento is a self-hosted platform for Linux virtual machines on a single libvirt/KVM host. A user creates an instance over SSH or from a web dashboard; Bento gives it a static address on the owner's private /24, boots it from an operator-allowlisted cloud image with cloud-init, and publishes it on the internet as NAME.<your-domain> (HTTPS through a wildcard-TLS proxy) and ssh NAME@<your-domain> (through an SSH frontend). One binary, bentod, runs everything.

The full system specification is SPEC.md. It is authoritative; read it before changing anything. DEPLOYING.md is the runbook for bringing a host up, with the traps the quickstart below leaves out.

Operator quickstart

DNS (SPEC 7.1) — create two records, both pointing at the host:

  1. an A record for bento.example.org
  2. an A record for *.bento.example.org

Host — a Linux machine with /dev/kvm, libvirtd answering on the local socket at qemu:///system, qemu-img, xorriso, and nft on PATH. Run bentod as a user in the libvirt group. bentod serve refuses to start if a requirement is missing (SPEC 4.2). Bootc OCI sources additionally require rootful Podman; Bento runs the configured, digest-pinned image-builder container privileged to produce qcow2 disks. serve checks Podman and its writable container storage when an OCI source is configured.

Config — copy bento.example.toml to /etc/bento/bento.toml and set at least base_domain, the [acme] Cloudflare token (the wildcard certificate needs DNS-01), the [oidc] provider for the dashboard, and one [[images]] entry. Then:

bentod fetch-images       # download and verify the image allowlist

Three processes (SPEC 4) — run each under systemd (a simple unit with ExecStart=/usr/local/bin/bentod <cmd>, Restart=on-failure, After=libvirtd.service is enough):

bentod serve    # control plane: database, policy, restore, dashboard (port 10080)
bentod proxy    # HTTPS proxy: port 443 and ports 3000-9999
bentod sshd     # SSH frontend and CLI: port 22

serve owns the database and prints its path at startup; back it up with bentod dump-db (never a raw file copy — WAL makes that unsafe) together with the image and storage directories (SPEC 12.1). The other operator commands are bentod reconcile (prints libvirt/database disagreements, changes nothing) and bentod images.

Users sign themselves up through OIDC: the first login for an identity your provider authenticates creates the account and allocates its /24 and libvirt network (SPEC 13). To use the command line, they then run ssh bento.example.org with an unknown key, open the three-minute link it prints, and confirm the fingerprint shown. Set allow_signup = false under [oidc] to freeze the user list. Grant quota with a quotas row. Names listed in operators in the config get the database download. They can also append bootc-compatible OCI OS images while Bento is running, either from the Images dashboard or over SSH:

ssh bento.example.org images add fedora-bootc quay.io/fedora/fedora-bootc:latest

The command pulls and builds immediately, and the durable database row survives process restarts. A failed first build rolls the row back so the name can be corrected and retried. An OCI source must be a bootable OS image with a kernel, cloud-init NoCloud support, and qemu-guest-agent baked in; Bento checks these before the privileged build. Ordinary application containers are not bootable by Bento. Granting operator access also grants an effective path to host root because operators choose input to that privileged build.

Development quickstart

Rust nightly (see rust-toolchain.toml); Node only if you touch the dashboard. The build needs a C compiler for the bundled SQLite, but not cmake or clang: every TLS user is pinned to the ring crypto provider, never aws-lc-rs.

make build       # target/release/bentod (dashboard assets embedded from web/dist)
make check       # cargo clippy -D warnings && cargo test --workspace
make unit        # the in-process tests only
make e2e         # the end-to-end suite: the real binary, over real sockets
make dashboard   # rebuild web/dist after changing web/src (npm ci && npm run build)

Everything host-touching (libvirt RPC, qemu-img, xorriso, nft, /dev/kvm) sits behind small traits with in-memory fakes, so the full test suite runs anywhere. On top of those, bentod/tests/e2e/ runs the shipped binary against a whole deployment in a temporary directory, with a fake libvirtd on a unix socket. See TESTING.md for what is real, what is substituted, and why. CI runs both tiers on x86_64 and arm64. Each crate declares the narrow trait it needs rather than depending on the data layer; bentod is the one place that knows every concrete type.

  • bentod — subcommand dispatch and the wiring of all crates.
  • crates/types — shared domain types (SPEC 11-12).
  • crates/config — TOML operator configuration; see bento.example.toml.
  • crates/store — SQLite schema and persistence (SPEC 12).
  • crates/hypervisor, crates/images, crates/cloudinit, crates/network, crates/lifecycle — host-side machinery (SPEC 5, 6, 11).
  • crates/sshfront, crates/cli — SSH frontend and command line interface (SPEC 10, 15).
  • crates/proxy, crates/tlscert — HTTP proxy and wildcard TLS (SPEC 8, 9).
  • crates/auth, crates/api, crates/dashboard, web/ — identity, API, and dashboard (SPEC 13, 14).

crates/hypervisor speaks the libvirt XDR RPC protocol directly over the unix socket rather than binding a C library, so the binary stays dependency-light. It implements only the procedures the control plane calls.

Status

Built to SPEC v0.9. Every crate has unit tests against fakes, and the end-to-end suite drives the real binary through the whole instance lifecycle (TESTING.md). The system has still not been run against a live libvirtd: no guest ever boots in a test, and the nftables ruleset and the ACME issuance are exercised only through their fakes. MULTI-NODE.md section 23 lists what a live acceptance run would have to cover. Known gaps and deviations:

  • console (serial console attach) is not wired; it returns a clear error. Use ssh NAME@<domain> instead.
  • rename requires the instance to be stopped: the libvirt domain carries the name and is redefined under the new one.
  • The three processes share the SQLite database over WAL from one host. SPEC 4 makes the control plane the only writer; in this build the SSH frontend also writes (CLI commands, pending key links). Single-host WAL with a busy timeout serializes them.
  • Dashboard sessions live in control-plane memory; a serve restart logs dashboard users out (API tokens are unaffected). The proxy checks sessions by forwarding credentials to the control plane.
  • OIDC is the only thing that creates an account, so a deployment with no working provider cannot admit anyone — not even over SSH, since the key-linking page needs a session.