mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
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.
This commit is contained in:
commit
5f9b2a251b
1 file changed
+293
@@ -0,0 +1,293 @@
|
|||||||
|
# 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`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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.
|
||||||
Reference in new issue
Block a user