Skip to content

[WIP] feat(minvmd,minimald): Stage 2 — minimald as guest pid-1 (e2e integration) - #362

Closed
norrietaylor wants to merge 35 commits into
mainfrom
minvmd-stage2-integration
Closed

[WIP] feat(minvmd,minimald): Stage 2 — minimald as guest pid-1 (e2e integration)#362
norrietaylor wants to merge 35 commits into
mainfrom
minvmd-stage2-integration

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jun 7, 2026

Copy link
Copy Markdown
Member

Draft integration branch combining #359 (block-root boot + rootfs package + RAW kernel) and #361 (vsock-native minimald), running minimald as the guest pid-1 with a full session over the libkrun bridge. Lands after #359 and #361.

Working end-to-end (Rust e2e test, exit-status accurate)

minimald_exec_over_bridge (gated MINVMD_E2E=1) drives a real russh client over a UnixStream to the bridge UDS and exercises the whole stack:

auth_none → CreateSession (SSH subsystem RPC) → exec "echo …" in the session
→ stdout="MINIMALD_SESSION_OK\n"  exit=Some(0)

i.e. host UDS → libkrun bridge → guest vsock → socat relay → minimald SSH → session exec, with correct stdout and exit status.

Foundation (all console/test-validated):

  • minimald cross-compiles to aarch64-unknown-linux-musl (static).
  • minvmd provisions + attaches a persistent rw data disk (/dev/vdb, sparse, guest-formatted); rootfs bakes the /var/lib/minimal mountpoint.
  • minimald boots as guest pid-1: mounts /proc+/sys, formats+mounts the data disk, emits READY, serves SSH over a socat vsock→UDS relay into run_on_uds.

Bugs found + fixed via the e2e

  • pid-1 reaper stole exec exit status: the eager waitpid(-1) SIGCHLD reaper raced tokio's process reaping (ECHILD → every command reported exit 1). Dropped it; tokio reaps its own children. Hakoniwa orphan reaping deferred (spec).
  • "bridge interop bug" was a test artifact: CLI tools (ssh/socat/nc) don't round-trip the bridge, and there's a post-READY startup race. A Rust client that retries works reliably — no libkrun/tokio-vsock defect.

Transport

Guest serves a UDS on the data disk fronted by a socat vsock→UDS relay (the bridge_e2e-proven vsock-listen + minimald's native run_on_uds). Direct tokio_vsock (run_on_vsock, retained as pub API) is unproven against this bridge and could replace the relay later.

Remaining (smaller now)

  • Orphan reaping compatible with tokio (for min run/hakoniwa under net=none).
  • Client/RPC wiring on the host CLI (minimal2 ls is a placeholder).
  • Productionize the rootfs bake (currently debugfs-injected; should be a build step).

Decisions baked in

Persistent rw data disk for state; host sparse image + guest-side format; pid-1 state/cache on the mounted disk (no HOME); cross-built minimald + init wrapper injected into the rootfs.

🤖 Generated with Claude Code

norrietaylor and others added 23 commits June 6, 2026 17:02
Define the minvmd guest rootfs as a minimal package instead of the
shell-script Alpine overlay. build.sh snapshots the closure of its
build_deps (socat + bash + coreutils + e2fsprogs), drops in the bring-up
init and the /etc/minvmd/manifest contract, prunes build-only bulk, and
packs an ext4 image with mke2fs. Cross-layer deps are referenced via
`upstream "<name>"`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump the upstream gominimal/pkgs lock 0da02c7 -> 133db0c (the commit that
adds the virtio-linux package; a 52-commit fast-forward) and add the
raw-file outputs minvmd materializes: virtio-kernel (the guest kernel)
and minvmd-rootfs (the guest ext4 image).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switch the guest root from a virtiofs directory (krun_set_root) to a
read-only ext4 block device. A block root has no libkrun /init.krun, so
the kernel runs the workload directly via an explicit cmdline
`root=/dev/vda rootfstype=ext4 ro init=<exec-target>`; the exec target
(MINVMD_EXEC, default /sbin/minvmd-stub-init) moves from krun_set_exec
into init=. Add the krun_add_disk2 binding + safe wrapper and an
exec_target field on VmConfig. MINVMD_ROOTFS_PATH now resolves a file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…source

Rewrite fetch-virtio-kernel.sh to build the minimal CLI from this repo
(cargo build -p minimal) and materialize virtio-kernel against the repo's
own minimal.toml, instead of downloading the promoted CLI from
gs://minimal-shim. Drop `minimal update` (it would rewrite the tracked
locked_commit and risk an uncached, full kernel build) and the scratch
project. Linux-only, with a guard pointing at crates/minvmd/README.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewire ci-macos.yml: the virtio-kernel job builds minimal from source
(toolchain + protoc + rust-cache); a new minvmd-rootfs job materializes
the ext4 image on the self-hosted aarch64 runner (the build container
does not emulate, so aarch64 packages need a native builder); boot-e2e
and autospawn-e2e consume both as artifacts. Delete fetch-alpine.sh and
build-rootfs.sh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add crates/minvmd/README.md (materialize kernel + rootfs into .scratch/,
build, codesign-last, run minimal2 ls; E2E invocation; boot mechanics)
and gitignore .scratch/. Closes the references from the kernel script and
the CI error messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
libkrun's in-VMM gzip decompress of the aarch64 Image.gz cost ~77 ms of a
~146 ms boot-to-READY (measured N=10). Ship the kernel uncompressed and
load it with KRUN_KERNEL_FORMAT_RAW, which skips the decompress: median
boot-to-READY drops 146 -> ~67 ms.

Add a local virtio-kernel-raw package that gunzips the upstream
virtio-linux Image.gz, repoint the virtio-kernel output at it, and make
RAW the aarch64 default in image.rs. The artifact grows 14 -> 38 MB,
which is trivial next to per-boot latency. (Measured separately: quiet
cmdline, vCPU count, and RAM size were all boot-noise.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The build script panicked when 'git rev-parse' produced no output (for
example cross-compiling inside a container with no repo mounted, or
building from a release tarball), because strip_suffix("\n").unwrap()
hit None. Fall back to a GIT_HASH of "unknown" instead so the build
succeeds outside a git checkout. This unblocks the aarch64 cross-build
used for the minvmd guest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make minimald capable of running as the guest pid-1 / kernel init=
target inside the minvmd microVM, replacing the socat stub init.

- Generalize Connection::from_socket into from_stream<S: AsyncRead +
  AsyncWrite + Unpin + Send + 'static>, keeping from_socket as a thin
  UDS wrapper. The russh stack was already transport-agnostic.
- Add Server::run_on_vsock(config, port): bind a tokio-vsock listener
  on CID_ANY and run the same accept -> from_stream -> JoinSet loop as
  the UDS path. The host-mediated vsock peer (net=none) is as trusted
  as the UDS peer, so connections are treated as local (Auth::Local).
- Add a new guest module (Linux-only) implementing the boot contract
  and pid-1 hygiene: emit the one-shot READY marker to host CID 2 port
  7350 with retry/backoff; mount /proc and /sys if absent (the kernel
  only auto-mounts devtmpfs on /dev); and reap orphaned children via a
  SIGCHLD waitpid(-1, WNOHANG) handler, since hakoniwa double-forks its
  namespace children.
- Wire up main.rs: '--guest' (or the MINIMALD_VSOCK_PORT env) selects
  guest mode, serving on '--vsock-port' (default 2222, the bridge port
  the host registers via krun_add_vsock_port2). The UDS path remains
  the default, unaffected, native-Linux behavior.

tokio-vsock is a Linux-only target dependency, so the macOS host build
is untouched. Verified by cross-compiling, clippy (-D warnings), and
testing minimald for aarch64-unknown-linux-musl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ckage

The virtio-kernel-raw package forced an arm64 build, which the x86_64 CI
runner cannot do ("cannot build spec with target arm64/linux on
amd64/linux") — the upstream virtio-linux kernel only worked there because
it is a cache pull, not a build. Drop the package and gunzip the
cache-pulled Image.gz in fetch-virtio-kernel.sh instead; gunzip is
arch-agnostic, so it runs on any runner while still yielding the raw Image
that KRUN_KERNEL_FORMAT_RAW loads (skipping libkrun's ~77 ms decompress).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`minimal check`'s import-line checker requires destructured minimal.ncl
identifiers in canonical order (lowercase first, then uppercase). Reorder
to `upstream, BuildSpec, Local, OutputData` so minimal-check passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/bench-minvmd-boot.sh times `minvmd boot` to the guest READY marker
across N runs and reports min/median/max. This is the harness that found
the gzip kernel decompress (~77 ms of ~146 ms) and verified the
uncompressed-kernel fix (~67 ms). macOS-only; needs a codesigned minvmd
and MINVMD_KERNEL_PATH/MINVMD_ROOTFS_PATH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The self-hosted runner cache-hit a minvmd-rootfs build entry that lacked
rootfs.img (an earlier build produced no output yet got cached), so
materialize failed with "copying output file ... rootfs.img: No such
file". Add a `command -v mke2fs` precondition and a post-build assertion
that the image exists and is non-empty (failing loudly instead of caching
an empty result). The build.sh content change also changes the package's
input hash, forcing a fresh build past the poisoned cache entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The image was sized tree + 5% + 4 MiB, but ext4's journal (~4 MiB+) plus
inode-table metadata that `du` does not count can overflow that on a
slightly larger closure — the likely cause of `mke2fs` producing no
rootfs.img on the CI runner. The root mounts read-only, so the journal is
pure overhead: build with `-O ^has_journal` and widen headroom to
tree + 10% + 8 MiB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tiofs)

The gating comments still described the old virtiofs path (PE_GZ load,
virtio-fs root, /init.krun, Alpine, VIRTIO_FS). Update to the committed
model: raw kernel load, ext4 block root via krun_add_disk2, kernel `init=`
exec, VIRTIO_BLK/EXT4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The self-hosted runner fails to collect rootfs.img into the build cache
despite a successful build (the build.sh output assertion does not trip),
and it is not reproducible locally on the same shim version. Echo the shim
version and, on failure, list the minvmd-rootfs output dir + most-recent
cache dirs so the next run reveals whether the image was produced (→ a
collection/disk issue) or not (→ a build issue).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diagnostics proved the minvmd-rootfs build succeeds and writes the image
to the host build cache (104 MB present), but `minimal materialize`'s copy
step cannot read it back through the shim VM's /host mount on the
self-hosted runner ("copying output file ... No such file" for a file that
exists on the host) — every build, same CLI version that works locally.
Work around it: the build still populates the cache, so on copy failure
take the freshly-built image straight from ~/.cache/minimal/built. TODO:
remove once the shim /host large-file read coherence issue is fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire scripts/bench-minvmd-boot.sh into the macOS boot-e2e job so every run
reports boot-to-READY min/median/max, and document it in the README.
Non-gating (boot correctness is gated by the e2e tests); reuses the
already-codesigned minvmd.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
As pid-1 the guest has no HOME and the root image is read-only, so the old
dirs::state_dir() path both panics and lands on a read-only fs. In guest
mode, mount a second virtio-blk device (/dev/vdb) at /var/lib/minimal and
resolve state + cache + the SSH host key underneath it, before serving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The host attaches a freshly-provisioned blank data disk, so the first
mount fails ("Can't find ext4 filesystem"); mke2fs it (the rootfs ships
e2fsprogs) and retry. Subsequent boots find a formatted disk and mount
directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The guest root is read-only, but minimald needs to write its session
store + SSH host key. Provision a persistent sparse raw image under the
state dir (the guest formats it, so no host mke2fs) and attach it rw as a
second virtio-blk device via krun_add_disk2; the guest mounts it at
/var/lib/minimal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/var/lib/minimal must exist in the read-only root image so the guest can
mount the writable data disk there (it cannot mkdir on a read-only root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 49e058ad-9ca5-4390-a35d-36d7cc0897fe

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands and usage tips.

@norrietaylor norrietaylor changed the title feat(minvmd,minimald): Stage 2 — minimald as guest pid-1 (e2e integration) [WIP] feat(minvmd,minimald): Stage 2 — minimald as guest pid-1 (e2e integration) Jun 7, 2026
norrietaylor and others added 5 commits June 6, 2026 23:45
Surface accepted-connection + session-future errors instead of silently
dropping the spawned future, so vsock-transport failures are diagnosable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
libkrun's krun_add_vsock_port2 bridge delivers a reliable byte stream to a
guest vsock listener (proven by minvmd's bridge_e2e), but CLI tools
(ssh/socat/nc) do not round-trip it — even the socat-echo stub returns
nothing via the CLI while bridge_e2e (UnixStream + read_exact) passes. So
the direct tokio-vsock listen vs the relay cannot be distinguished with
manual ssh; both need a Rust SSH-client harness to validate.

Guest mode now relays vsock<port> -> a UDS on the writable data disk via
socat (reusing the bridge_e2e-proven vsock listen) and serves it with the
native run_on_uds path. Adds accept + session-error logging to both serve
loops so handshake failures are visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A reliable Rust counterpart to ad-hoc ssh/socat/nc testing (which does not
round-trip the bridge): boot minimald as the guest, connect a UnixStream to
the bridge UDS, and assert an SSH-2.0 banner comes back from the in-guest
minimald (russh) server — proving host UDS -> libkrun bridge -> guest vsock
-> socat relay -> minimald SSH. Gated MINVMD_E2E=1; retries the exchange to
absorb the post-READY startup race (matching bridge_e2e).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The vsock->uds relay is validated by the SSH-session e2e; remove the
`-d -d` diagnostic flags so the relay is quiet on the guest console.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drives a real russh client over a UnixStream to the bridge UDS: authenticate
(auth_none, local peer), CreateSession RPC over the SSH subsystem, then exec a
command in that session and assert stdout + exit status. Proves host UDS ->
libkrun bridge -> guest vsock -> socat relay -> minimald SSH -> session exec.
Gated MINVMD_E2E=1; retries the session to absorb the post-READY startup race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
norrietaylor and others added 3 commits June 7, 2026 01:36
The eager waitpid(-1) SIGCHLD reaper raced tokio's process reaping and stole
exec children's exit status (ECHILD -> every command reported exit 1). Drop it:
tokio reaps its own children, so exec now reports the real exit code. Reaping
hakoniwa double-fork orphans needs a tokio-compatible reaper and is deferred
(spec: revisit if zombies bite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records minvmd's new dev-dependency edges (russh, tokio, sessions, paths)
added for the session e2e test, so CI's --locked build resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Experiment (swap the relay for direct Server::run_on_vsock) showed the prior
rationale was wrong: a direct tokio-vsock listener *does* accept the bridged
connection. It fails one layer up — the SSH session over the bridged vsock
stream dies with "Protocol error: early eof" within ~13ms of accept, so the
client hangs. libkrun's bridged vsock does not sustain a full bidirectional
SSH session (bridge_e2e's single short request/response works). socat
terminates the vsock and gives russh a stable UNIX-domain stream; the session
e2e completes only with the relay. Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@norrietaylor

Copy link
Copy Markdown
Member Author

Can we drop socat once #361's run_on_vsock lands? Tested it — no, not yet.

Swapped the socat relay for direct Server::run_on_vsock and ran the session e2e. Result:

minimald listening on vsock port=2222
accepted vsock connection peer=cid: 2 ...     ← run_on_vsock DOES accept
vsock session ended with error error=Protocol error: early eof

So the earlier "the bridge doesn't deliver to a tokio-vsock listener" was wrong — it accepts fine. It fails one layer up: the SSH session over the bridged vsock stream dies ~13 ms after accept with early eof, and the client hangs waiting for the RPC response. The socat→UDS relay carries the identical session to completion (exit 0).

So the relay is doing real work — libkrun's bridged vsock doesn't sustain a full bidirectional SSH session (bridge_e2e's single short request/response does), and socat terminates the vsock + hands russh a stable UNIX stream. Corrected the code comments in d5db15e.

Dropping socat would require root-causing the bridged-vsock early-eof (a libkrun vsock/TSI lifecycle question), not just wiring run_on_vsock. Tracking as follow-up; relay stays for now.

norrietaylor and others added 2 commits June 8, 2026 10:54
…kages

Now that gominimal/pkgs ships microvm-rootfs (ext4 guest rootfs) and
virtio-kernel-raw (uncompressed kernel Image), drop the local stopgaps and
dogfood the upstream packages:

- minimal.toml: point [outputs.virtio-kernel] at virtio-kernel-raw (raw Image,
  no gunzip) and [outputs.minvmd-rootfs] at microvm-rootfs; bump locked_commit.
- Delete the local .minimal/packages/minvmd-rootfs/ package.
- fetch-virtio-kernel.sh: drop the gunzip step (the kernel is already raw).
- Retarget the default guest exec /sbin/minvmd-stub-init -> /sbin/microvm-init
  (the upstream stub) and the manifest path; update docs + the CI cache path.

Validated locally: both artifacts materialize from the upstream packages and
boot_e2e + bridge_e2e pass against them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… job

Now that the rootfs comes from the upstream microvm-rootfs package, its
prebuilt aarch64 artifact is in the public cache — so `materialize --arch
aarch64` is a cache pull, not a native build, and works on a cheap x86_64
Linux runner (exactly like the kernel). Materialize both in one `artifacts`
job; the self-hosted runner only boots.

- Generalize scripts/fetch-virtio-kernel.sh -> fetch-artifact.sh <output> <dest>.
- Merge the virtio-kernel + minvmd-rootfs jobs into one Linux `artifacts` job.
- Drop the self-hosted minvmd-rootfs materialize job and its shim /host
  large-file read workaround.
- Point boot-e2e/autospawn-e2e at `needs: [artifacts]`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
norrietaylor and others added 2 commits June 8, 2026 11:32
~67 ms was a one-off from a less-loaded machine; the reproducible CI bench is
~113 ms median (runner-dependent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…invmd-stage2-integration

# Conflicts:
#	.minimal/packages/minvmd-rootfs/build.sh
norrietaylor added a commit that referenced this pull request Jun 8, 2026
… rootfs)

minimald ships as the initramfs /init (a ~27 MB cpio) and serves a full session
against the GENERIC upstream microvm-rootfs — no minimald baked into the rootfs,
no local rootfs package, no data disk, no mke2fs.

- minvmd: VmConfig.initramfs + krun_set_kernel initramfs arg (MINVMD_INITRAMFS);
  attaches the rootfs as /dev/vda for the initramfs /init to mount. Also carries
  the vsock guest-mode plumbing (data disk, etc.) for the block-root path.
- minimald: runs as /init (detect via argv[0]); mounts devtmpfs; mounts /dev/vda
  + chroots into the rootfs so /bin/sh + socat resolve; serves over the socat
  vsock->UDS relay + run_on_uds with tmpfs (/run/minimal) state.
- Session e2e (minimald_exec_over_bridge) booted via initramfs against the
  generic upstream microvm-rootfs: exec stdout correct, exit 0.

Stacks on the generic-rootfs Stage 1 (#367); supersedes the local-rootfs
integration (#362). Persistent state (data disk + mke2fs) is a follow-up; the
session runs on tmpfs here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@norrietaylor

Copy link
Copy Markdown
Member Author

Superseded by #368 (Stage 2 via initramfs — minimald as pid-1 from a cpio against the generic upstream rootfs; no local rootfs package, no debugfs baking). The initramfs approach was spiked, benchmarked (on par with block-root boot), and proven on CI. Closing the local-rootfs integration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant