The freshness digest check and the distro checksum fetch kept the
total 30 s request timeout the transfer clients just lost. They
move to connect + read windows, matching the one policy.
Config::load was never called: the DNS-override story it carried
contradicts the actual resolver flow, and nothing in the CLI or
snap advertised a key. Delete the module and every mention of the
ecrou.yaml file in SPEC and the snap; the XDG_CONFIG_HOME redirect
in the command chain served only that file.
validate_memory_string predates vm::parse_memory_mib and nothing
calls it; the qemu_* name mappers and their tests served the QEMU
engine that never shipped. The Arch variants stay: they feed the
debian/OCI/Alpine name mappings and the host-arch detection.
What a downloaded image is trusted to be, and where verification
stops: TLS as the only anchor for manifests, digest-checked blobs,
advisory distro checksums, cache entries trusted once written.
Concurrent runs of one image are safe, not efficient.
KvmUnavailable and ArchNotAvailable were raised as anyhow context
strings and wrapped into Other at the public boundary, so the
matching lib.rs documents never fired. They now ride at the top of
an anyhow chain and the manual From recovers them by downcast;
KvmUnavailable carries the open(2) error as its source instead of
swallowing it.
This changes the API: KvmUnavailable is a struct variant.
Downloads, OCI bundle packing and rootfs packing all wrote through
one fixed partial name, so two processes preparing the same entry
interleaved bytes and renamed the wreckage into place for good.
Each writer now gets a unique partial file (utils::partial_path)
and the last atomic rename wins with whole bytes either way; the
layer pointer is written the same way.
The 300 s client timeout covered the whole body stream, so any
download slower than a couple of MB/s died mid-transfer. Connect
and per-read windows catch dead peers and stalls instead; slow
transfers now finish.
The termios save ran after fork, so a child that reached raw mode
first would be captured and later restored as-is. A caught signal
interrupted waitpid, failed the run, and left the machine running;
EINTR now retries the wait.
Whiteout markers trusted the archive's own path, so a crafted
`.wh.` entry carrying `..` could delete host directories above
the rootfs. Both branches now go through `safe_join`, and a
`.wh..` marker, whose target is the parent directory itself, is
refused with them.
Testing runs the pre-commit trio on pushes to main and PRs.
Releasing builds the snap on plain vX tags and publishes it to the
stable store channel; rc tags fail the version gate and leave the
publish job skipped.
The description carried a confinement section SPEC.md already owns,
and the part comments narrated build steps the commands state on
their own. First run now shows a plain run instead of the microVM
proof.
The founding rule restated SPEC.md, which stays the source of truth
for the confinement budget. Commit bodies gain a length cap, and a
commit must build and work on its own; bisectability outranks the
one-component-per-commit split when the two collide.
The run interface read like a library's. `ecrou run -it alpine` now
hands the guest a terminal: with no console output file, libkrun wires
the guest console to the process's own stdio and raw-modes the tty.
The parent saves and restores the termios around the run, because
libkrun's own restore lives in the child's exit observers, which a
SIGKILLed machine never runs. A tty run refuses to start without a
terminal on stdin and stdout, and --capture refuses to combine with
-t: the payload's streams cannot land in files and on the terminal at
once.
-i without -t keeps the machine's stdin open through pipes: libkrun
exposes the non-tty streams as virtio console ports (krun-stdin,
krun-stdout, krun-stderr), and the guest init points the payload's
stdio at whichever port nodes exist. `echo hi | ecrou run -i alpine --
cat` crosses the machine; a stream the host left on a tty has no port
node and stays on the console.
-v (--volume HOST:GUEST[:ro]) replaces --share, and -w aliases
--workdir. The global -v/--verbose loses its short flag to make room;
verbose stays reachable by its long form.
A reference starting with ./, ../ or / names a local rootfs archive:
read in place on every run, never downloaded, never cached as a
pristine entry. Provisioning over one persists into a derived cache
entry keyed by size and mtime, so the caller's file is never
overwritten and an edited archive lands in a fresh entry.
core26 base, strict confinement, the kvm interface for acceleration
with home and network auto-connected. The runtime needs nothing else:
the host process never mounts a filesystem and never creates or
enters a namespace, so it already lives inside snapd's default-deny
seccomp allowlist. The built .snap artifact is ignored.
A provision command baked by hand - `ecrou provision alpine -- apk
add ...` - cannot be reviewed or shared: the artifact worth keeping is
a diff in git, not a shell history line. A spec file is that diff.
`ecrou provision --spec image.yaml` parses base, packages and a run
block, derives the provisioning script from the base's own package
manager (apt, apk, dnf, discovered at provision time on the running
machine), and bakes the result into a cache entry keyed by the spec's
content hash: the same file never provisions twice, an edited file
never reuses the old layer, and the key hashes the raw bytes so the
layer is stable across ecrou versions.
`--layer NAME` names the entry, and `ecrou run --layer NAME` boots it
directly - no image resolution, no download; the layer is whatever
the provision call persisted under that name. A keyed entry has no
tarball of its own, so the base downloads only on the first
provisioning and every later run extracts the finished layer.
prepare_provisioned and persist were sitting there with no consumer:
the machinery for "install once, every later boot carries it" that
one-shot machines cannot do on their own - packages, users, anything
short of a share - and no way to reach it.
ecrou provision IMAGE -- COMMAND is the missing surface. It boots a
machine over a freshly prepared rootfs, runs the command, and
persists the tree into the cache entry, marked provisioned; a marked
entry is extracted as-is on every later call, the command skipped.
A failing command leaves the entry unprovisioned for the next
attempt, and --no-cache re-downloads before re-provisioning.
The engine behind run splits out as vm::run_prepared: the boot half
of run, taking the rootfs the provisioner is about to persist. run
itself is prepare plus that.
One cache fix the provision flow surfaced: a digest refresh replaced
the tarball but left the old .provisioned marker, so a fresh pristine
image would present as provisioned and skip provisioning for good.
The marker now dies with the entry it belonged to.
With preservation on, the crate chmods every file to the mode it
ships as it extracts, and one refused bit killed the whole run:
alpine's usr/bin/chage ships setgid-shadow (02755, group shadow),
and an extracting context that cannot set the bit - an overlayfs
tmp, a confined sandbox - fails the chmod with EPERM and takes the
boot down with "failed to set permissions to 2755".
The crate's own chmod now runs with the special bits masked off,
which no filesystem refuses, and the shipped modes ride the fixups
like directory modes already do: applied after the walk, degraded
to plain rwx when the filesystem refuses them, exact everywhere
else. Unprivileged tar degrades the same way; the guest re-applies
whatever it needs. The setgid regression test pins the round trip.
The console is one stream, kernel and guest mixed, so a consumer that
needs the payload's stdout and stderr apart - rhema's sandbox seam
reports them as separate fields - could not get it. With capture set,
the run adds the directory as an internal rw share (guest path
/.ecrou-io, reserved) and the init wraps the payload in a redirection
group pointing at it: the streams land host-side as DIR/stdout and
DIR/stderr, written live, owned by the host user, and there when run()
returns. The console stays on stdout for diagnostics; the CLI exposes
the flag as --capture.
KVM runs only the host architecture, so the flag could not mean what
it suggested: a mismatched value downloaded a foreign rootfs that
extracted cleanly and died at boot with a cryptic guest-side exec
error. The flag's real job, picking the image variant to fetch, is
the library's; RunOptions.arch keeps that role and an empty value
still resolves to the host architecture.
The engine materializes a heuristics-driven TSI by default; disable
it and add an explicit vsock hijacking AF_INET so guest networking is
deterministic rather than sniffed. Throughput is unchanged in this
build (TLS crawls at tens of KB/s against a CDN either way) - that is
the new ln driver's own pace and belongs upstream; the flag at least
pins down which path the guest takes.
Copying /etc/resolv.conf verbatim gives the guest systemd-resolved's
stub at 127.0.0.53, and loopback never crosses the engine's socket
proxy: the name lookup dies inside the guest. Source the uplink
config from /run/systemd/resolve first and strip 127.x nameserver
lines from whatever is copied.
The engine proxies guest sockets to the host's stack, so the host's
resolv.conf works in the machine as-is, loopback stub included. The
images ship no usable resolver, so without this every fetch inside
the guest fails to resolve.
RunOptions carried quiet/verbose, which are flags, not engine state.
They are now a LogLevel enum (Quiet/Normal/Verbose) mapped onto
libkrun's filter; the CLI maps its flags onto it. The forked child's
last-gasp boot diagnostics also stop going straight to stderr: they
land in the console file the parent streams, with stderr kept for the
case where the console was never configured. lib.rs now states the
crate contract: no UI in the library, progress through the sink,
logging through the facade, typed errors out.
'ecrou run alpine -- "apk add x && y"' reached the guest as one argv
element and execvp'd a program named after the whole line. One
argument containing whitespace is a quoted shell line: hand it to the
image's shell as 'sh -c'. Argv vectors stay literal, including ones
that begin with sh -c.
The exit-code contract needed a rethink once the real libkrun was in
the loop: on a normal payload exit the VMM ends its own process with
the payload's code, so run() forks and the child enters the VM loop.
The parent reaps it and the code flows to the consumer; the control
file stays as the boot-failure diagnostic, and the guest script no
longer powers off - the init blob does that when the script exits,
since the libkrunfw kernel has no sysrq.
The console cannot ride stdio: with a tty on fd 0 libkrun stops a
background caller with SIGTTOU, and without one it routes guest bytes
through its logger. So the console goes to a file in the scratch that
the parent streams to real stdout, with a final drain after the reap;
the child unwires fd 0 to /dev/null so the terminal is never touched
and a Ctrl-C to the process group still reaches the guest. PDEATHSIG
keeps T4: a killed consumer is a killed machine. The hostname and
workdir now ride the set_exec config natively (the init blob applies
them); log level follows quiet/verbose.
Also in the CLI: the command after '--' was parsed but never copied
into RunOptions, so every run executed the image's default shell.
run maps the flags onto RunOptions, promotes the first read-write share
to the workspace, inherits stdio, and exits with the guest's code; probe
reports what a run would find (KVM, libkrun loadability with the
remediation, nested virt, cache writability) and fails when a run
would. T6, the no-mount audit, lives here where the binary is built,
ignored behind ECROU_BIN for CI.
run() probes KVM, prepares the rootfs through the cache, generates the
guest init into the scratch, wires a libkrun context to it, and blocks
in krun_start_enter on this thread; the exit code comes back through
the control file, and a killed consumer kills the machine with the
process. Shares are validated and tagged in one place so the script and
the virtiofs setup stay ordered; anything overlapping the scratch
rootfs is refused, since the guest could otherwise rewrite its own
init. The guest environment is composed from scratch; the host
environment never crosses.
Generated into the scratch rootfs before boot: mounts each virtiofs
share (ro through the mount option), runs the payload as a child so the
init survives to write its exit code to the control file, then powers
off through sysrq, which works without a poweroff binary in the rootfs.
Shell quoting keeps payload argv and share paths free of interpolation.
Eleven calls from the stable-1.19.x headers plus krun_free_ctx, loaded
at runtime so a missing install names the remediation instead of failing
to link. Symbol lookup goes through a macro that carries the signature
from libkrun.h, every unsafe block states its invariant, and negative
returns map onto errno errors. The KVM probe ports ecr's open-then-
KVM_GET_API_VERSION check and reports KvmUnavailable with the full
remediation text.
ecrou's workspace starts from ecr's confinement-neutral half: image
reference resolution, the content cache with digest freshness, layer
extraction with whiteouts and deferred directory modes, provisioned
hot cells, and the progress/error/config plumbing. Adaptations: the
cache lives in ~/.cache/ecrou, the namespace-era error variants and
utils constants are gone, KvmUnavailable arrives ahead of its caller,
rootfs loses its exec() delegation, and uname(2) goes through libc
now that nix is no longer a dependency. The CLI crate is a stub until
its commit.
QEMU was the committed v1 engine with libkrun later; that order is
flipped. libkrun deletes the initramfs pack, the kernel fetch, slirp
config, and the uid mapping problem, and its VMM runs in-process, which
collapses the kill chain. Verified against the stable-1.19.x headers:
eleven-call v1 surface, krun_set_workdir native, mapped volumes dead on
Linux (tagged virtiofs plus guest init mount instead), KVM required
with QEMU as the later TCG fallback. Snap spike now stages libkrun
plus libkrunfw instead of qemu-system.
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.