Commit Graph
76 Commits
Author SHA1 Message Date
vhaudiquet a368a2e7cb fix(image): run provision unlinks when no files are staged
The early return keyed on files alone, so an unlink-only call was a
silent no-op. No caller passes an empty file set today, but the
signature invites one, and the control-file unlink is the case that
must never vanish.
2026-10-30 23:13:03 +01:00
vhaudiquet 4b3c66af3c docs(vm): state the machine child's fork constraint
The fork contract lived in a comment inside run_prepared; an
embedder meeting it first in the public API docs, with the
fork-with-threads hazard spelled out, has a chance of honoring it.
2026-10-30 23:13:03 +01:00
vhaudiquet 943aa8b38f docs(cli): state the memory size unit
--memory takes MiB, not bytes: -m 512 is 512 MiB. The help said
bytes.
2026-10-30 23:12:50 +01:00
vhaudiquet dd4139ce65 fix(rootfs): boot a cached distro entry for floating aliases
Resolution ran before the cache was consulted, so every run of
alpine:3.23, ubuntu or any floating alias hit the CDN or
changelogs.ubuntu.com, and an offline run of a cached image died in
resolution instead of booting. The boot path now asks the cache
first: an alias boots the newest cached entry of its distro, within
the series or the edge flavor it floats in, and resolves upstream
only on a miss; pull and --no-cache still resolve upstream.

Exactly-named references (Ubuntu YY.MM, known codename, Alpine X.Y.Z)
resolve offline on every path; resolve_source folds into the policy,
which lives with the cache it consults.
2026-10-30 23:12:50 +01:00
vhaudiquet dfc8cf88e7 fix(cache): re-check every unpinned OCI tag on pull
Only :latest was treated as floating; any other unpinned tag skipped
the registry digest check, so a moved :v1.2 kept booting stale. Tags
are mutable pointers, that is what digest pins exist for; the pin
skip stays, the tag skip goes.
2026-10-30 23:12:25 +01:00
vhaudiquet 5df06ffcc2 fix(distro): take the last supported release with rfind
filter(..).next_back() on a double-ended iterator is denied by newer
clippy, and CI floats on stable, so it went red there while passing
locally.
2026-10-30 23:12:25 +01:00
vhaudiquet 4affe9230c fix(image): publish the built image atomically
mke2fs creates and sizes the output file before it populates it, so a
build that failed mid-way left a recent-looking image full of zeros at
the final name - newer than the tarball, and trusted by the freshness
check forever after. The first run after the libarchive fix booted
exactly that wreckage: EXT4-fs "Can't find ext4 filesystem".

The build now lands in a sibling .part file and renames over the image
only on success, and the freshness check additionally requires the
ext4 superblock magic (s_magic, at byte 1080) before trusting an
existing image - caches already carrying wreckage rebuild over it on
the next run instead of needing a manual clean.
2026-10-30 21:22:33 +01:00
vhaudiquet 0347309eea ci: install libarchive for the image build
Same dlopen as the snap stages for: the runner has e2fsprogs but not
the lib, and the first cold pull would fail. The t64 name with a
fallback keeps it honest if the runner image changes base.
2026-10-30 21:22:33 +01:00
vhaudiquet f3da39011e build(snap): stage libarchive for the image build
Populating an ext4 image from a tarball is a runtime dlopen of
libarchive in mke2fs, not a linked dependency: the deb does not pull
it in, the stage set did not have it, and the first snap build failed
with "you need libarchive to be able to process tarballs". Stage
libarchive13t64 next to e2fsprogs.
2026-10-30 21:22:33 +01:00
vhaudiquet ed4214180e docs: describe the image-backed rootfs
The design paragraphs follow the code: the rootfs crosses as a raw
ext4 image built from the tarball, ownership is real inside the
machine, provisioning rides debugfs, and the control file mints the
exit code because the engine's own channel is virtiofs-root only.
The confinement budget admits the e2fsprogs children and cp, the
snap section stages e2fsprogs and names the blk feature, and the
boot-economics row counts an image copy instead of an extraction.
2026-10-30 19:10:59 +01:00
vhaudiquet 17e0d91527 feat(cli): probe the image tooling
The run path shells out to mkfs.ext4 and debugfs from e2fsprogs; a
host without them fails at the first pull, so probe names them up
front like it names KVM and the engine.
2026-10-30 19:10:59 +01:00
vhaudiquet 27b22c60ab ci: build the T6 engine with the blk feature
The T6 run boots from a block device now; an engine without the blk
feature fails the lookup for krun_add_disk and the audit never sees a
machine. Same feature set as the snap ships.
2026-10-30 19:10:59 +01:00
vhaudiquet 583d188474 build(snap): build the engine with blk and stage e2fsprogs
The rootfs boots from a virtio-blk disk now, and the blk feature is
what compiles the device into libkrun - without it krun_add_disk and
krun_set_root_disk_remount have no symbols and every run fails at
lookup. e2fsprogs joins the stage set for mkfs.ext4 (the cache image
build) and debugfs (per-run provisioning and the control-file read);
core26 ships 1.47.4, which knows how to build from a compressed
tarball.
2026-10-30 19:10:59 +01:00
vhaudiquet 51f56116a3 feat(rootfs): cross the rootfs as an ext4 image on a block device
The rootfs was a virtiofs share over an extracted tree, and that
showed: the tree's owners were the host user's, because an
unprivileged extract cannot chown to a tar's uid, and every chown
inside the machine failed with EPERM against the host side.

The rootfs crosses as a raw ext4 image now. mke2fs builds the cache
image straight from the entry tarball (-d takes gz, xz and zst, and
bakes each entry's recorded uid, gid and mode), the run copies it
into a scratch directory with cp --reflink=auto --sparse=auto, and
the engine attaches it with krun_add_disk +
krun_set_root_disk_remount; the init blob mounts /dev/vda at / and
chroots. The OCI layer bundle merges into one plain tar first, with
whiteouts applied across layers and headers passed verbatim, so
registry images keep their ownership too.

Per-run provisioning rides debugfs: the generated init lands at
/ecrou-init, the host resolver overwrites /etc/resolv.conf (unlink
first - resolver-bearing images ship it as a symlink), a stale
control file goes. The exit-code control file now outranks the
machine child's exit code: with a block root the engine's exit-code
channel (an ioctl through the virtiofs root device) never fires, so
decode_status reads the file first and treats the child's code as
machinery talk. Shell detection and the rare control-file read are
debugfs stats, no extracted tree to walk.

extract.rs keeps what the image build consumes (the tar decoder and
the OCI merge) and drops the extraction-to-disk path it once carried;
the module is private now. The engine needs the blk feature from here
on: the snap and the CI engine build follow in their own commits.
2026-10-30 19:10:48 +01:00
vhaudiquet 8cc3ba9854 build(snap): rewrite the store listing text
The summary takes the README tagline. The description now leads with
an Install section, shows a first run that opens a shell, and leaves
the XDG redirection internals out of the listing.
2026-10-30 16:23:55 +01:00
vhaudiquet 2ac01f992a build: adopt the snap version from Cargo.toml
The version: key in snapcraft.yaml was a second number to bump by
hand, and between releases it read whatever it was left at. The ecrou
part now sets its version from [workspace.package] at pull time —
the same value clap reports with --version — so Cargo.toml is the
only place a version lives, and the release workflow stamps only
that. A missed match fails the pack loudly.
2026-10-30 16:19:10 +01:00
vhaudiquet a021df7d72 build(snap): fill the store metadata
The pack lint flagged five empty metadata fields. They render on the
snapcraft.io listing, and contact is asked for at publish time. The
repo answers for website, source-code, and contact (the issue tracker,
since the listing is public); title matches the binary name.
2026-10-30 16:15:10 +01:00
vhaudiquet 06856d532f docs(cli): plainen the command one-liners
The list view now says what each command does in the plainest verbs:
run boots a command from an image, pull downloads one, probe checks
the requirements. The long help keeps the contracts — freshness
semantics, what probe actually tests.
2026-10-30 16:03:23 +01:00
vhaudiquet 0ad07c182f refactor: cut provision
The runtime runs images; building them belongs to the OCI ecosystem —
CI builds, digests, scanning — and a keyed local layer could never
offer a digest. Provision also installed packages through TSI, whose
per-stream throughput makes a real package install crawl exactly at
first use, and the bare-command shape mutated the shared cache entry
under the image's own name.

0.1 runs images: run, pull, probe — distro aliases, OCI references
with digest pins, local rootfs archives. The one honest idea provision
carried stays in SPEC's Later: provision by booting, for locked-down
hosts that cannot run any builder.
2026-10-30 15:57:10 +01:00
vhaudiquet e632fa2125 docs: move provisioned layers out of Later
The keyed-layer design shipped: provision --spec hashes the spec with
its base reference and --layer names the entry for run --layer. The
row was describing the past as the future; what remains is eviction.
2026-10-30 15:30:22 +01:00
vhaudiquet 3d999b7be0 docs(cli): rewrite the command help
Each command keeps a crisp one-liner in the command list and carries
its contract in the long help: how an image reference resolves, what
provision's two shapes do, why pull exists (run never contacts a
registry on a cache hit), and what probe checks. Flag help states the
real constraints instead of the shortest noun phrase: the guest path
of a share must be absolute, the host environment is never inherited,
--capture is refused with --tty, --quiet silences engine and kernel
messages.
2026-10-30 15:30:22 +01:00
vhaudiquet 4aadc5d28a docs: trim SPEC.md to the contracts the code cannot carry
The code is the source of truth now; what stays is the part code
cannot carry — the founding rule and confinement budget (fixed: the
budget said no child process, the machine child is one), the run
contracts (exit codes rewritten to match the engine-failure and
reserved-code semantics), the trust model, the falsification tests,
and the roadmap.

Cut: the engine call list, the RunOptions listing, the CLI flags
table, the cache layout tree, the workspace/module map, the ecr
porting table, and the open questions — all answered or all mirrors
of code that answers them faster.
2026-10-30 13:27:51 +01:00
vhaudiquet b0ff40dd5d ci: note why the release builds no riscv64 snap
GitHub hosts no riscv64 runner; the platform stays buildable by hand
(snapcraft remote-build).
2026-10-30 03:45:23 +01:00
vhaudiquet f47f9e77dd build(snap): build riscv64 from the ports archive
libkrunfw5 ships for riscv64 in ubuntu-ports, not the main archive
(archive.ubuntu.com carries amd64 and arm64 binaries); the part now
maps each target arch to its pool and a pinned digest.
2026-10-30 03:45:23 +01:00
vhaudiquet 2132a8262d fix(error): name the KVM module per compile target
The remediation hard-coded kvm_intel, wrong on arm64 and riscv64
which the snap now ships. x86_64 keeps the vendor split (kvm_intel,
kvm_amd) and the nested-virt parameter file; everywhere else KVM is
one module and the nested pointer is not actionable.
2026-10-30 03:45:23 +01:00
vhaudiquet 3d86f30dd3 fix(supervise): kill and reap the machine when a run errors after the fork
A parent-side failure after the fork (a console stat or read that
went wrong, a wait that could not complete) used to return and leave
the VM booting unattended. PDEATHSIG only fires when the caller's
process exits; a library consumer that catches the error keeps
running, and the scratch rootfs goes away under the machine.
kill_machine is best effort: a machine that already exited reaps
through ECHILD. Also drops a duplicated fork-error block left behind
by the supervise split.
2026-10-30 03:45:23 +01:00
vhaudiquet 2e6c84b65b fix(supervise): report engine failures instead of guest exit codes
Only the guest init can mint a guest code: it reports the payload's
code through the control file and the VMM exits the child with it. A
signal on the machine child therefore means the VMM or the engine
died; mapping it to 128+n masqueraded an OOM-killed VMM as the
payload having died by that signal (T3).

125 and 126 keep their two-sided reading: the child bails with 125 on
a boot failure and the init refuses with 126 on a failed share or a
missing workdir, but a payload may genuinely exit either code — the
control file is the arbiter, and a run that never reached the payload
is an error naming the cause.
2026-10-30 03:44:55 +01:00
vhaudiquet 0ce450b19d docs: drop the README-reflection rule from the conventions 2026-10-30 03:42:26 +01:00
vhaudiquet 8047106733 test(cli): audit the strace trace by syscall name, not errno
The SPEC bar for T6 is failure on a single hit from the denied set;
the errno-based check only caught attempts the snap's seccomp
allowlist had already denied, so a host mount that succeeded would
have passed. Exact-name matching over the traced call also stops
"mount" matching "umount2" lines.
2026-10-30 03:26:07 +01:00
vhaudiquet 4a9d148a4b ci: run the T6 strace audit on every push
The test has been declared as CI-only since it landed and never ran
anywhere. The job builds the engine from the tag the snap bundles,
takes libkrunfw from the pool, opens /dev/kvm, and runs the ignored
test with ECROU_BIN and LD_LIBRARY_PATH pointed at them.
2026-10-30 03:26:07 +01:00
vhaudiquet 8bdaa7de69 ci: build the arm64 snap in the release workflow
The arm64 runner leg builds that platform natively, where libkrun
compiles and the libkrunfw part picks the arm64 pool .deb on its own.
Both snaps upload as artifacts and publish to stable from one job.
2026-10-30 03:26:07 +01:00
vhaudiquet 5072fbcbed build(snap): build arm64 and drop the riscv64 target
The pool carries an arm64 libkrunfw5 next to the amd64 one, so the
part now picks the flavor and the install path from the target arch
(CRAFT_ARCH_BUILD_FOR / CRAFT_ARCH_TRIPLET_BUILD_FOR) with the
checksum pinned per arch. riscv64 goes: the pool has no libkrunfw5
build for it, and libkrun does not target it.
2026-10-30 03:25:49 +01:00
vhaudiquet b4dafccae7 feat(cli): add ecrou pull
Fetch or refresh an image's cache entry without booting it. The
command is the CLI face of the run/pull split: runs never contact a
registry on a cached image, pull does - reporting the entry path
and whether anything transferred. --no-cache forces a redownload.
SPEC documents the command and the run/pull trust model.
2026-10-30 01:04:24 +01:00
vhaudiquet 12a417645d feat(rootfs): pull as the explicit freshness operation
prepare was a freshness hybrid: runs of an unpinned OCI tag contacted
the registry on every boot, and only ":latest" was checked - :main
floated in name only. Docker's split is cleaner, so it is the model
now: prepare (and run) trust a present entry and never touch the
network on a hit - a cold cache pulls by default, since there is
nothing to boot otherwise. RootfsCache::pull is the refresh
operation: re-check any unpinned tag against the registry digest,
re-resolve floating distro aliases, download only what moved,
extract nothing. Digest pins stay immutable to both. The trade, as
in docker: a stale :latest boots until someone pulls.
Local archives are not pullable - the error says so.
2026-10-30 01:04:22 +01:00
vhaudiquet 408774cbd1 docs: add the cache scope to the commit conventions 2026-10-30 01:04:22 +01:00
vhaudiquet c2e8d82cd3 feat(progress): report uncounted status through the sink
A run blocked on the cache-entry flock was silent: the wait went to
the log sink, which renders nowhere by default, so the CLI read as
a hang. The Progress trait gains message() - a status line outside
the counted-operation protocol, defaulting to a no-op so existing
sinks keep compiling - and the flock wait reports what it is
waiting for. The CLI renders messages on the active bar and spins
one up when no operation is active.
2026-10-30 01:04:20 +01:00
vhaudiquet 89a1028d46 refactor(cache): name cache entries without touching the network
generate_cache_filename derived a distro tarball's extension by
resolving its URL - a naming function with a network dependency and
a silent unwrap_or_default degradation. Naming is now pure string
shaping: distro artifacts are gzipped tarballs, so the name says
.tar.gz, and a future artifact format changes extraction and this
line together. Same filenames as before for every existing entry;
a caller skipping resolve_source now gets a deterministic key
instead of a lookup.
2026-10-30 00:45:00 +01:00
vhaudiquet 479dfb79d0 refactor(rootfs): split the entry cache into cache.rs
The cache stopped being an implementation detail of the rootfs
lifecycle: entry naming, the advisory lock, digest freshness,
provisioned-layer pointers, and extraction-to-scratch are a store
in their own right. cache.rs now owns RootfsCache's paths, pointers
and extraction products; rootfs.rs keeps the lifecycle API
(PrepareRequest, PreparedRootfs, prepare, prepare_provisioned).
resolve_source moves next to its inputs in distro. Pure movement -
naming still derives its extension from the URL, next commit
changes that.
2026-10-30 00:44:46 +01:00
vhaudiquet 52d734063f fix(rootfs): validate the layer key at the entry-path splice point
entry_path spliced the layer key into the cache filename raw: the
guard lived at the write/extract/prepare boundaries, so a library
consumer deriving paths through resolved_entry_path could get a
path outside the cache directory instead of the named error.
entry_path now returns Result and validates, prepare drops its now
redundant pre-check.
2026-10-30 00:24:36 +01:00
vhaudiquet 73eaeb57ac feat(distro): ubuntu-base from real releases, codename and number aliased
Ubuntu tarballs came from the daily/current directories - moving
targets, no published checksums guarantee, gone when a release
EOLs. resolve_ubuntu_url now builds the cdimage release URL:
ubuntu-base/releases/<YY.MM>/release/ubuntu-base-<YY.MM>-base-<arch>.tar.gz,
whose SHA256SUMS the existing checksum verification consumes.

Resolution returns the YY.MM number (the cache key follows), and
accepts the codename interchangeably: a static table maps the
known codenames offline - ubuntu:resolute and ubuntu:26.04 are the
same image and the same entry - with meta-release as a fallback
for codenames newer than the table. Floating aliases (latest/lts)
still consult meta-release; explicit numbers never touch the
network, which also removes the per-cache-path network lookup
generate_cache_filename used to trigger for codename inputs.

URLs verified live: tarball and SHA256SUMS both 200.
2026-10-30 00:12:51 +01:00
vhaudiquet 28f24abb6f feat(distro): accept digest-pinned OCI references
Docker pulls ubuntu@sha256:... and repo:tag@sha256:...; ecrou
errored on the '@'. parse_oci_ref now peels the pin (which contains
the reference's only legal post-slash colon), validates it as
algo:hex, and the manifest is fetched by digest - the tag, when
co-present, is ignored the way docker ignores it. Pinned
references are immutable by definition: the :latest freshness
check skips them, and the cache filename carries the pin so a
tagged ref and its pin never share an entry. Pull parameters
gather into registry::OciPull.
2026-10-30 00:08:27 +01:00
vhaudiquet 200aac1f89 fix(rootfs): hold an advisory flock while filling a cache entry
Two concurrent cold runs of the same image both downloaded it; the
atomic rename made it safe, not cheap. prepare now takes an
exclusive flock on a <entry>.lock sidecar before downloading, so a
second run waits and reuses the winner's entry - re-deriving the
freshness answer under the lock, since the digest it saw may be
its own download. no_cache callers skip the lock: an explicit
fresh download never waits. Locks release on process death, so a
crashed run cannot wedge the cache.
2026-10-30 00:04:08 +01:00
vhaudiquet 6bece1098b fix(guest): diagnose and fail on mount errors in the init
A virtiofs mount that failed (bad fsconfig, missing share) was
swallowed: the payload ran without its data and the only symptom
was a confusing failure downstream. The init now names the share
tag and target on the console and exits 126 - the same contract as
an unreachable workdir - instead of booting a run that cannot do
its job.
2026-10-30 00:03:04 +01:00
vhaudiquet f3cbf16180 fix(vm): keep caller shares out of the reserved guest namespace
A caller share targeting /.ecrou-io (or inside it) shadowed the
capture share's files, and one at /.ecrou-exit or '/' shadowed the
exit handshake; collect_shares never looked at guest targets. The
capture share itself still owns the namespace by design; workspace
and --share are validated like any other caller share. The overlap
check takes the rootfs path directly.
2026-10-30 00:00:43 +01:00
vhaudiquet f9250accc0 fix(rootfs): confine layer names to the cache directory
A layer name lands verbatim in file paths: layers/<name> for the
pointer, <entry>.<key>.tar.gz for keyed entries. --layer ../x
escaped the cache directory on write and read alike. Names are now
restricted to [A-Za-z0-9._-] without a leading dot, enforced at
every surface that accepts one (write, read, extract, and the
prepare_provisioned layer key).
2026-10-29 23:58:07 +01:00
vhaudiquet dfeb282a26 fix(extract): name the unsupported bzip2 format instead of misparsing it
A .tar.bz2 tarball fell through extract's compression dispatch to
the magic-byte fallback and died as garbage tar errors; a bzip2
distro URL would spend the whole transfer first (download now
rejects it before spending the transfer). No bzip2 decoder exists
in the tree, and none is planned.
2026-10-29 23:57:37 +01:00
vhaudiquet a49cba488b fix(progress): close the sink on failed transfers
A download that died mid-stream (read error, digest mismatch)
returned before progress.finish(), leaving the sink with an open
operation — a lingering progress bar in the CLI and a broken
begin/finish pairing for any other sink.
2026-10-29 23:56:38 +01:00
vhaudiquet e4f712b782 refactor(download): one blocking HTTP stack, no tokio runtime
The library is blocking end to end — vm::run waits on the machine
for the payload's whole lifetime — so the async interior bought no
concurrency (OCI layers download sequentially) and nothing any
consumer shape could use; each call rented a tokio runtime with
'Failed to create Tokio runtime' as a user-visible failure mode.
download.rs and registry.rs move to reqwest::blocking, folding the
runtime wrappers away; distro.rs joins the same client shape,
which also gives its metadata calls their first timeouts. The
shared client bounds the connect and, for small metadata bodies,
the whole request; transfers stay unbounded (the blocking client
has no per-read window; a stalled stream is the operator's
Ctrl-C). tokio and futures-util drop out as direct dependencies;
tokio remains transitively under reqwest.
2026-10-29 23:56:20 +01:00
vhaudiquet a758689bbe refactor(vm): split machine-process supervision into supervise.rs
vm.rs composed run options and supervised the machine process in
one file. The process half moves out: the fork that enters the VM
loop, the console relay, the wait and exit-status decode, and the
termios save/restore. run_prepared keeps the composition and calls
into the new module. No behavior change.
2026-10-29 22:54:05 +01:00
vhaudiquet 3e839cb5ca refactor(rootfs): split the tarball packer into pack.rs
rootfs.rs mixed the cache lifecycle with the tree-to-tarball
writer that persisting relies on. The packer moves to pack.rs
beside extract.rs: pack_rootfs becomes crate-internal, its
partial-cleanup test moves with it. No behavior change.
2026-10-29 22:52:08 +01:00
vhaudiquet aa95d05f41 refactor(download): split the OCI registry client into registry.rs
download.rs carried two clients in one file: the plain transfer
plumbing and a full OCI Distribution Spec client (token auth,
manifest lists, layer blobs). The registry half moves to its own
module; download.rs keeps hashing, checksum files and file
transfers. fetch_oci_digest moves with it; rootfs imports it from
the new home. No behavior change.
2026-10-29 22:50:49 +01:00
vhaudiquet bb61be4625 fix(download): bound the metadata clients like the transfers
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.
2026-10-29 22:49:09 +01:00
vhaudiquet 47c9208aab refactor(spec): drop the rhema references
The spec file is a plain provisioning spec, not a .rhema/ artifact;
the test base image needs no personal registry name.
2026-10-29 22:47:05 +01:00
vhaudiquet 1223512729 refactor(rootfs): remove the unused PreparedRootfs accessors
arch() and cache_entry() have no callers; the fields feed the
module's own flow.
2026-10-29 22:47:05 +01:00
vhaudiquet 7461b20349 refactor(krun): remove the unused kvm_available probe
No caller: the CLI probes through kvm_probe, which reports the
failure mode instead of a boolean.
2026-10-29 22:45:13 +01:00
vhaudiquet 006fce632b refactor(config): remove the unwired config module
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.
2026-10-29 22:45:04 +01:00
vhaudiquet 12148ac951 refactor(utils): remove the QEMU-era helpers
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.
2026-10-29 22:44:51 +01:00
vhaudiquet d9d9b1c99e docs: state the image trust model
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.
2026-10-29 22:42:28 +01:00
vhaudiquet 185ffeeb3d fix(error): let the typed variants survive the anyhow conversion
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.
2026-10-29 22:42:21 +01:00
vhaudiquet 56af198d54 fix(rootfs): make cache writes safe against concurrent runs
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.
2026-10-29 22:42:12 +01:00
vhaudiquet 6835056be9 fix(download): drop the total request timeout from transfers
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.
2026-10-29 22:41:59 +01:00
vhaudiquet 8ceab3130d fix(vm): save the tty before the fork and retry waitpid on EINTR
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.
2026-10-29 22:41:50 +01:00
vhaudiquet 1e69044198 fix(extract): resolve whiteout paths like every other entry
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.
2026-10-29 22:41:43 +01:00
vhaudiquet 7c50b19c09 ci: add testing and snap release workflows
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.
2026-10-29 22:08:06 +01:00
vhaudiquet 8a0521ae76 build(snap): trim the description and part comments
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.
2026-10-29 21:47:25 +01:00
vhaudiquet ed5786c4b5 docs: slim the agent conventions
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.
2026-10-29 21:47:19 +01:00
vhaudiquet 062fdca59d docs: add a README
How to install and run the snap, and how to embed the library: one
RunOptions example covers a basic machine. The logo lives under
.github/img.
2026-10-29 21:47:14 +01:00
vhaudiquet 4a0c613ecb feat(cli): docker-style run flags and local rootfs references
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.
2026-10-29 18:08:30 +01:00
vhaudiquet 719a8209e0 build(snap): package ecrou as a strictly confined snap
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.
2026-10-29 18:06:54 +01:00
vhaudiquet 878ce29b8d feat(cli): provision layers from a spec file
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.
2026-10-29 18:06:50 +01:00
vhaudiquet dad5f61833 docs(spec): keyed provisioned layers under Later 2026-10-29 00:13:48 +01:00
vhaudiquet d9f9c79dc4 feat(cli): ecrou provision bakes a command into a cache entry
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.
2026-10-28 16:20:37 +01:00
vhaudiquet 1fb3f0c076 fix(extract): modes degrade instead of killing the unpack
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.
2026-10-28 15:30:30 +01:00
vhaudiquet 23fcd713b6 feat(vm): capture the payload's output into a host directory
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.
2026-10-28 13:37:33 +01:00
vhaudiquet db2658f385 docs: drop the removed --arch flag from the CLI table 2026-10-28 13:20:08 +01:00
vhaudiquet 74c3744d39 fix(cli): remove the --arch flag
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.
2026-10-28 13:20:02 +01:00