Files
ecrou/SPEC.md
T
Valentin Haudiquet 5f9b2a251b docs: the ecrou specification
ecrou descends from ecr: the microVM runtime that survives strict snap
confinement. The founding rule (no host mounts, no namespaces, every
crossing a file or a socket) is stated with its syscall budget, sourced
from snapd's seccomp allowlist; execution model, exit-code propagation
through a private control share, kill-chain cancellation, the spike-snap
gate, and seven falsification tests round out the v1 design.
2026-10-27 21:30:13 +01:00

13 KiB

ecrou - implementation specification

Identity

ecrou is a microVM runtime for disposable Linux environments. It descends from ecr, the ephemeral chroot: the name keeps the ancestor, and the answer to "chroot ou VM" is now VM. As a bonus, an écrou is a nut: the small part that keeps a bolt from walking out of the wall.

One sentence: resolve an image reference, boot it as a one-shot microVM, run one command with live stdio, propagate its exit code, keep whatever crossed the workspace share, throw the machine away.

The project is library-first like ecr: crates/ecrou is the library (package ecrou), crates/ecr-cli becomes crates/ecrou-cli (binary ecrou), a thin front-end that parses flags, maps them onto the library API, and propagates exit codes. The library never writes to the terminal: diagnostics go through the log facade, long operations report through the Progress sink, all output is the CLI's.

The first consumer is rhema's sandbox seam, which shells out to the binary the way it shells out to docker and lxc today. The library API is designed so a second consumer can arrive without forking the runtime.

What carries over from ecr

module disposition
distro, download, extract, rootfs, utils, progress ported; the image machinery is confinement-neutral plain fs and network
qemu_vm (cpio builder, KVM probe, launch) ported and grown; becomes initramfs + qemu + vm
kernel ported; Alpine linux-virt fetch, cache entry renamed
namespace, chroot, mount, exec dropped; they stay in ecr, their ancestor. Their error variants (UserNamespacesUnavailable, BinfmtNotRegistered, MissingSubidRange) leave with them

No cache compatibility with ecr: the formats may match but the directory is ~/.cache/ecrou and never shared.

The founding rule

ecrou's host process never mounts a filesystem and never creates or enters a namespace. Every boundary crossing is a file or a socket. This is the property that lets ecrou run inside a strict snap's default-deny seccomp allowlist, where every namespace-based runtime, runc, crun, podman, and ecr's own chroot mode, dies at mount.

The rule is enforced by audit, not by trust: test T6 straces a full run and fails on a single hit from the denied set. It runs in CI.

Guest-side mounts are not host mounts. Confinement binds the host process; the guest kernel may mount proc, devtmpfs, and the workspace share inside the machine, and nothing a guest does lands on the host beyond what the shares expose.

Confinement budget

Everything the host process may do, and nothing else:

  • fs: read, write, stat, rename, unlink, mkdir under the cache directory and the share roots it was given, plus its own private tmpdir
  • exec: the kernel it boots and qemu-system-* (later: libkrun), found in PATH or staged in the snap
  • /dev/kvm: open, ioctl, mmap. Optional; TCG is the fallback when the probe finds no working KVM (KVM_GET_API_VERSION != 12)
  • net: TCP and UDP client sockets for registry fetches, slirp's host side, and localhost listeners only through explicit hostfwd
  • process: fork, exec, waitpid, and signals for its own children

Denied by design: mount, umount2, unshare, setns, pivot_root, open_tree, move_mount, mount_setattr, fsopen, fsmount, ptrace, kernel module loading, keyring calls. All absent from snapd's seccomp allowlist, which is where the allowed list above is drawn from. Host mknod is allowlisted but AppArmor mediates what it can create; ecrou has no use for it either way.

Execution model

One-shot machines. ecrou run IMAGE -- CMD boots one VM whose init execs CMD as the only workload; when CMD exits, the machine powers off. There is no exec into a running machine. No engine in the family has that API: libkrun's krun_set_exec is per-context before start, and crun's krun handler boots a fresh VM for every crun exec. A guest agent and long-lived machines are a later row; per-command boots are the v1 shape, and the boot budget below is what keeps them honest.

Ephemeral rootfs, durable workspace. The base system is an initramfs: it exists only in RAM and dies with the machine. Persistence crosses a workspace share or it does not happen.

Workspace shares. --share HOST:GUEST[:ro], served by 9p (qemu -virtfs local). The host side is plain fs under one more directory the budget covers; the guest init mounts it. There is no implicit share: an empty sandbox is the default, and the consumer chooses what crosses. This is deliberate; a sandbox runtime does not silently attach the caller's cwd.

Exit codes. The machine has a private control share: a host tmpdir the API does not surface, mounted read-write at a fixed guest path. The init writes CMD's exit code to a file there, then powers off; ecrou reads it back and exits with it. Signal deaths map to 128+signal, the shell convention the init already produces. Alternatives considered and rejected for v1: isa-debug-exit (x86-only, awkward from a shell init) and semihosting exit (arch-dependent). If the exit file is absent and QEMU itself failed, that is an engine failure, reported as one, never as a guest code.

Cancellation is a kill. ecrou-cli spawns QEMU with PR_SET_PDEATHSIG and a kill-on-drop guard, so a killed consumer kills the machine. No orphan QEMU survives the CLI. This is stronger than docker exec's cancel, which kills only the local client; the tests keep it true (T4).

Console. -serial stdio, no monitor mux. The QEMU monitor is a host-level control hatch over the machine; a sandbox runtime does not mux it into the workload's stdio by accident. --monitor restores mon:stdio for development.

Networking. slirp user-mode only. The init configures the guest statically (10.0.2.15/24, gateway 10.0.2.2, DNS 10.0.2.3); no DHCP client is assumed. No tap, no bridges, ever: they need host setup the budget cannot afford. hostfwd stays available for a later sandboxd-in-guest.

Env and argv ride the kernel command line base64-encoded, ecr's trick, extended to the environment. Sane size is a few KB; oversized payloads are a caller bug and fail loudly.

Memory. Default 1G, -m to override. ecr's CLI defaulted 2G; a per-command machine is provisioned per command, and agent consumers run several concurrently.

The guest side

The init is ours, a generated shell script packed into the initramfs and selected with rdinit=. It grows from ecr's (hostname, shell detection, argv decode, sysrq poweroff fallback) with static net config, share mounts, the workdir cd, and the exit-code handshake added. It uses only what the image ships; busybox is not assumed.

Kernel: Alpine linux-virt per target arch (the linux-lts fallback when no virt flavor exists), fetched and cached as today. The pack step adds the booted kernel's /lib/modules tree into the initramfs: 9p and virtio_net are modules in linux-virt, and the distro image's module tree does not match the kernel we boot. The size cost is measured in T5, and gzip compression of the initramfs is the knob if it hurts.

Workspace layout

Cargo workspace, two crates, the ecr split:

  • crates/ecrou - the library. Modules: config, distro, download, error, extract, initramfs, kernel, progress, qemu, rootfs, utils, vm.
  • crates/ecrou-cli - the CLI (binary ecrou).

The library must not depend on CLI types; clap is a CLI-only dependency. Everything else from ecr's rules stands: anyhow internal, ecrou::Error semantic variants at the sites that detect them, log for narration, Progress for long operations.

Library API

Rootfs lifecycle (ported)

RootfsCache::new(dir) / default_dir(), prepare, prepare_provisioned, persist, PrepareRequest, PreparedRootfs: ecr's semantics verbatim, including digest freshness on :latest and the .provisioned hot-cell marker. See ecr's SPEC for the reference description; differences are the cache directory and the kernel entry names only.

vm::run

pub struct Share { pub host: PathBuf, pub guest: String, pub read_only: bool }
pub struct RunOptions {
    pub image: String,          // distro ref or OCI image ref
    pub arch: String,           // empty = host arch
    pub memory: String,         // default "1G"
    pub workspace: Option<Share>,   // the one rw share consumers name
    pub shares: Vec<Share>,     // additional named shares, ro allowed
    pub command: Vec<String>,   // empty = the image's default shell
    pub workdir: String,        // guest cwd; empty = first share's guest path, else /
    pub env: Vec<(String, String)>, // composed envp; host env never inherited
    pub hostname: String,       // empty = generated ecrou-vm-XXXXXX
    pub quiet: bool,
    pub require_kvm: bool,
}
pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32>

stdio is inherited; the return value is the guest exit code. A require_kvm run with no working KVM is Error::KvmUnavailable with the remediation text. Everything else is Error::Other over the anyhow chain, ecr's shape. The v1 API has no captured-output variant and no long-lived machine; consumers pump the child's stdio themselves when they drive the CLI.

CLI

ecrou run [OPTIONS] <IMAGE> [-- COMMAND...]
ecrou probe
Flag Default Description
-a, --arch <arch> host arch Target architecture
--share <HOST:GUEST[:ro]> none Workspace share; repeatable
-m, --memory <size> 1G Guest memory
--workdir <guest path> first share, else / Guest cwd
-e, --env <KEY=VALUE> none Guest env; repeatable
--require-kvm false Fail instead of falling back to TCG
--monitor false Dev: mux the QEMU monitor onto stdio
--quiet false Suppress kernel log on the console
--no-cache false Fresh download, ignore the cache
-v, --verbose false Diagnostics

ecrou probe reports what a run would find: KVM working or the TCG fallback, the QEMU binary, the kernel in cache, the cache directory writable. It is the confinement diagnostic and the support tool.

File layout

~/.cache/ecrou/
├── ubuntu-noble-amd64.tar.gz
├── alpine-edge-x86_64.tar.gz
├── <...>.digest                        # manifest digest sidecar
├── <...>.provisioned                   # hot-cell marker
└── ecrou-default-kernel-x86_64.vmlinuz

~/.config/ecrou.yaml carries over (DNS overrides today).

Snap packaging

Confinement: strict. Plugs: network, home, kvm. Manual snap connect for kvm is accepted for now; auto-connect is a store request and a later product decision.

The first milestone is the spike snap, ecr's VM mode behind strict confinement, before any porting: stage qemu-system-x86_64 and the kernel in the snap, or fetch both through the cache in $HOME. Both fit the budget; T1 is the gate that proves the shape under real AppArmor and seccomp, and everything else in this document is design until it passes.

Falsification tests

  • T1, confinement. The spike snap runs ecrou run alpine -- uname -a under strict confinement. The guest kernel version differing from the host is the microVM proof, the same check crun's krun man page prescribes.
  • T2, workspace coherence. In a shared workspace, the guest runs git init, add, commit, status; the host sees the commits; a second machine sees the first's writes. Git's mmap'd index is the trap this test exists for.
  • T3, exit codes. ecrou run alpine -- sh -c "exit 7" exits 7. A guest signal death exits 128+signal. A killed QEMU reports an engine failure, never a guest code.
  • T4, kill chain. SIGKILL ecrou-cli mid-run; QEMU is gone within a second, no orphan, verified by scanning the process table.
  • T5, boot economics. KVM boot-to-init for alpine with linux-virt lands under 1.5 s including the module-tree pack cost. TCG numbers are recorded, not gated.
  • T6, no-mount audit. strace -f -e trace=mount,umount2,unshare, setns,pivot_root,open_tree,move_mount,fsopen,fsmount over a full run with shares: zero hits. This test is the founding rule.
  • T7, guest network. An https fetch from the guest succeeds.

Later

  • libkrun engine. The FFI lives here; ecrou already carries unsafe libc, and rhema keeps its forbid(unsafe_code). Sub-second boots, the kernel bundled in libkrunfw, TSI networking instead of slirp. Same budget, one more engine.
  • Tiny initramfs. A fixed small init with the rootfs itself served over 9p, killing the per-run pack cost and the RAM floor.
  • Guest agent, long-lived machines. Exec without a boot; the shape to grow into when per-command VMs stop being right.
  • passt if slirp throughput ever matters.
  • sandboxd-in-guest over hostfwd: rhema's http backend pointed into a machine.
  • Rootfs persistence through prepare_provisioned hot cells, already in the ported code.

Open questions

  • 9p versus virtiofsd as the share transport. 9p needs no extra daemon; virtiofsd is the modern path but is another staged binary. Decide when T2 runs; the Share API does not change either way.
  • Initramfs compression default: uncompressed boots fastest, gzip halves the RAM floor. Measure in T5.
  • Whether probe belongs in the library or stays CLI-only.