8000 feat(minvmd): extend libkrun support to Linux/KVM by norrietaylor · Pull Request #414 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions .github/workflows/ci-linux-kvm.yml
8000
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
name: CI (Linux KVM)

# minvmd Linux/KVM end-to-end on a GitHub-hosted runner (#410, #411).
#
# Split from ci.yml and PATH-SCOPED so it runs only when the minvmd surface
# changes: it builds libkrun (incl. libkrunfw, which compiles a kernel) from
# source, so it is far heavier than a normal lane and should not burden unrelated
# PRs. Unlike the earlier self-hosted incarnation it needs no preprovisioned
# runner — KVM is available on GitHub-hosted x86_64 Linux runners, and libkrun is
# built + cached here.
#
# x86_64 ONLY: GitHub-hosted arm64 runners (ubuntu-24.04-arm) do not expose
# /dev/kvm, so the Linux/KVM e2e can only run on x86_64. aarch64 + libkrun boot
# is already covered by the macOS lane (ci-macos.yml, Apple Silicon / HVF).
#
# NON-GATING: absent from ci.yml's `ci-success`, so a KVM/runner hiccup never
# blocks a PR. The boot/session/bridge e2e tests assert correctness; the daemon
# lifecycle step exercises `run --detach`/`status`/`stop`; the latency benchmark
# is informational.
on:
push:
branches: ["main"]
paths:
- "crates/minvmd/**"
- "crates/minimald/**"
- "scripts/**"
- "Cargo.toml"
- "Cargo.lock"
- ".minimal/minimal.toml"
- ".github/workflows/ci-linux-kvm.yml"
pull_request:
branches: ["main"]
paths:
- "crates/minvmd/**"
- "crates/minimald/**"
- "scripts/**"
- "Cargo.toml"
- "Cargo.lock"
- ".minimal/minimal.toml"
- ".github/workflows/ci-linux-kvm.yml"
workflow_dispatch:

env:
CARGO_TERM_COLOR: always

# Cancel superseded runs so the heavy libkrun build isn't queued on stale commits.
concurrency:
group: ci-linux-kvm-${{ github.ref }}-${{ github.workflow }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

permissions:
contents: read

jobs:
minvmd-linux-kvm-e2e:
runs-on: ubuntu-latest # x86_64; KVM-capable
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false

- name: Toolchain
uses: dtolnay/rust-toolchain@stable

- name: Install build dependencies
# protoc + libprotobuf-dev: the `minimal` CLI (materialize) build. The
# well-known protos (google/protobuf/*.proto) live in libprotobuf-dev,
# which protobuf-compiler only pulls via recommends — so name it
# explicitly since we use --no-install-recommends. The rest: libkrunfw's
# kernel compile (flex/bison/bc/libelf/libssl/pyelftools/cpio) and
# libkrun's bindgen + link (clang/libclang/pkg-config/patchelf).
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
protobuf-compiler libprotobuf-dev \
build-essential flex bison bc libelf-dev libssl-dev \
python3 python3-pyelftools cpio \
clang libclang-dev pkg-config patchelf git

- name: Enable /dev/kvm access
# GitHub-hosted x86_64 Linux runners expose /dev/kvm but the runner user
# is not in the `kvm` group; widen the node so libkrun can open it. Fail
# fast with a clear message if this runner has no KVM at all.
run: |
if [ ! -e /dev/kvm ]; then
echo "::error::/dev/kvm not present on this runner; cannot run the Linux/KVM e2e." >&2
exit 1
fi
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls -l /dev/kvm

- name: Resolve build paths
# Publish the libkrun prefix and guest-artifact paths (plus the E2E gate)
# to $GITHUB_ENV for later steps; `runner.temp`/`$HOME` are not available
# in job-level `env:`.
run: |
{
echo "KRUN_PREFIX=$HOME/.krun"
echo "MINVMD_E2E=1"
echo "MINVMD_KERNEL_PATH=$RUNNER_TEMP/vmlinuz"
echo "MINVMD_ROOTFS_PATH=$RUNNER_TEMP/rootfs.img"
echo "MINVMD_INITRAMFS=$RUNNER_TEMP/initramfs.cpio"
} >> "$GITHUB_ENV"

- name: Restore libkrun cache (libkrunfw v5.5.0 + libkrun v1.19.0)
id: krun-cache
# Keyed on the pinned versions: the kernel compile only runs on a miss.
# Bump the suffix when scripts/build-libkrun.sh changes pins.
uses: actions/cache/restore@v5
with:
path: ${{ env.KRUN_PREFIX }}
key: x86_64-libkrun-fw5.5.0-krun1.19.0-blk-stamped

- name: Build libkrun from source (cached)
run: ./scripts/build-libkrun.sh "$KRUN_PREFIX"

- name: Save libkrun cache
# Save right after the build so the expensive kernel compile is cached
# even when a later step (e2e/bench) fails — the default post-job save is
# skipped on job failure. Skipped on a cache hit (keys are immutable).
if: steps.krun-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: ${{ env.KRUN_PREFIX }}
key: x86_64-libkrun-fw5.5.0-krun1.19.0-blk-stamped

- name: Export libkrun link + runtime paths
# LIBKRUN_PREFIX: minvmd build.rs link-search dir (it emits an rpath to
# it too). LD_LIBRARY_PATH: libkrun.so -> libkrunfw.so.5 at runtime.
run: |
{
echo "LIBKRUN_PREFIX=$KRUN_PREFIX/lib64"
echo "LD_LIBRARY_PATH=$KRUN_PREFIX/lib64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
} >> "$GITHUB_ENV"

- name: Install cross
uses: taiki-e/install-action@v2
with:
tool: cross

- name: Cache cargo + build artifacts
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-x86_64-minvmd-kvm-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-x86_64-minvmd-kvm-

- name: Materialize guest kernel (raw Image)
run: ./scripts/fetch-artifact.sh virtio-kernel "$RUNNER_TEMP/vmlinuz" x86_64

- name: Materialize guest rootfs (ext4 image)
run: ./scripts/fetch-artifact.sh minvmd-rootfs "$RUNNER_TEMP/rootfs.img" x86_64

- name: Build guest initramfs (minimald as /init)
run: ./scripts/build-initramfs.sh "$RUNNER_TEMP/initramfs.cpio" x86_64-unknown-linux-musl

- name: Build minvmd
run: cargo build -p minvmd --bin minvmd

- name: Boot E2E (READY round-trip, R3.1)
env:
MINVMD_BOOT_LOG: ${{ runner.temp }}/boot.log
run: cargo test -p minvmd --test boot_e2e -- --include-ignored --nocapture

- name: Session E2E (full minimald session over the bridge)
run: cargo test -p minvmd --test minimald_session_e2e -- --include-ignored --nocapture --exact minimald_exec_over_bridge

# NOTE: bridge_e2e is intentionally NOT run here. It targets the Stage-1
# guest "vsock stub" (a socat/cat echo on port 2222) that minimald-as-pid1
# replaced with a direct SSH session server, so it now reads back the SSH
# banner instead of its echo payload. The macOS lane likewise runs only
# boot_e2e + minimald_session_e2e; session coverage of the bridge is
# provided by Session E2E above plus the daemon lifecycle step below.

- name: Daemon lifecycle (run --detach → status → stop, R4.2/R4.3/R4.4)
# Exercises the supervised daemon path that manages state.toml lifecycle,
# which the raw `boot` e2e tests above do not cover.
env:
MINVMD_BOOT_LOG: ${{ runner.temp }}/boot-daemon.log
run: |
set -euo pipefail
BIN=target/debug/minvmd

echo "::group::run --detach"
"$BIN" run --detach --timeout 30
echo "::endgroup::"

echo "::group::status (expect running)"
# `run --detach` returns once the host UDS accepts connections, which
# libkrun opens early in VM setup — slightly ahead of the supervisor's
# Starting->Running transition (set after the guest READY marker). Poll
# status (up to ~15s) for Running rather than asserting immediately.
# jq parses structurally (preinstalled on GitHub runners) so the check
# doesn't break on harmless JSON formatting changes.
for _ in $(seq 1 75); do
"$BIN" status --json > status.json || true
if jq -e '.state == "running" and (.vmm_pid | type == "number")' status.json >/dev/null; then
break
fi
sleep 0.2
done
cat status.json
"$BIN" status # exit 0 == Running
jq -e '.state == "running" and (.vmm_pid | type == "number")' status.json
echo "::endgroup::"

echo "::group::stop"
"$BIN" stop
echo "::endgroup::"

echo "::group::status (expect stopped)"
# `status`/`status --json` exit 1 when stopped (documented), so capture
# without tripping set -e / pipefail, then assert structurally.
"$BIN" status --json > status-after.json || true
cat status-after.json
jq -e '.state == "stopped"' status-after.json
# Also confirm the documented non-zero exit for the stopped state.
if "$BIN" status; then echo "::error::expected non-zero status exit after stop"; exit 1; fi
echo "::endgroup::"

echo "daemon lifecycle OK"

- name: Boot latency benchmark (informational)
# Report boot-to-READY min/median/max. Non-gating (correctness is gated
# by the e2e tests above), so a timing hiccup never reds the build.
run: scripts/bench-minvmd-boot.sh 10 target/debug/minvmd || true

- name: Upload guest boot console logs
if: always()
uses: actions/upload-artifact@v4
with:
name: minvmd-linux-boot-log-x86_64
path: |
${{ runner.temp }}/boot.log
${{ runner.temp }}/boot-daemon.log
if-no-files-found: ignore
86 changes: 75 additions & 11 deletions crates/minvmd/build.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,85 @@
//! Build script for `minvmd`.
//!
//! On macOS, wires up the link search path and rpath for libkrun. The libkrun
//! prefix defaults to `/opt/homebrew/lib` (the Homebrew tap install location
//! used by `~/code/min-ctl`); override with the `LIBKRUN_PREFIX` env var.
//! Wires up the link search path and rpath for libkrun and emits the
//! `minvmd_libkrun` cfg that gates the real (libkrun-linking) implementation.
//! Without that cfg the crate compiles to a runtime-bailing stub and never
//! links libkrun.
//!
//! On Linux this is a no-op — the crate compiles to a runtime-bailing stub
//! and never links libkrun.
//! - **macOS**: libkrun is always linked (Hypervisor.framework backend). The
//! prefix defaults to `/opt/homebrew/lib` (the Homebrew tap install location);
//! override with `LIBKRUN_PREFIX`.
//! - **Linux**: libkrun (KVM backend) is linked only when it is actually present
//! — detected via `LIBKRUN_PREFIX` or a scan of the usual library dirs. Stock
//! Linux CI without libkrun installed therefore still builds the stub, so the
//! Linux-only workflow stays green; a Linux host *with* libkrun builds the
//! real implementation with a plain `cargo build -p minvmd`.
//! - **Other targets**: stub only; never links libkrun.

use std::path::Path;

fn main() {
println!("cargo:rerun-if-env-changed=LIBKRUN_PREFIX");
// Declared so `#[cfg(minvmd_libkrun)]` does not trip the unexpected-cfgs
// lint under `-D warnings`.
println!("cargo::rustc-check-cfg=cfg(minvmd_libkrun)");

let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();

let prefix = match target_os.as_str() {
// macOS always links libkrun: it is the only VM backend on the platform
// and the existing proof artifacts assume it is provisioned.
"macos" => Some(
std::env::var("LIBKRUN_PREFIX").unwrap_or_else(|_| "/opt/homebrew/lib".to_string()),
),
// Linux links libkrun only when it can be found, so a host without it
// (e.g. the stock Ubuntu CI runners) still builds the no-op stub.
"linux" => find_libkrun_prefix(),
_ => None,
};

if std::env::var_os("CARGO_CFG_TARGET_OS").as_deref() != Some("macos".as_ref()) {
return;
if let Some(prefix) = prefix {
println!("cargo:rustc-link-search=native={prefix}");
println!("cargo:rustc-link-arg=-Wl,-rpath,{prefix}");
println!("cargo::rustc-cfg=minvmd_libkrun");
}
}

/// Locate the directory holding `libkrun.so` on Linux.
///
/// Honours `LIBKRUN_PREFIX` first (set it when libkrun lives outside the
/// standard search dirs), then scans the usual locations. Returns `None` when
/// libkrun is not installed, which selects the stub build.
fn find_libkrun_prefix() -> Option<String> {
if let Ok(prefix) = std::env::var("LIBKRUN_PREFIX")
&& !prefix.is_empty()
{
return Some(prefix);
}

const CANDIDATES: &[&str] = &[
"/usr/local/lib",
"/usr/lib",
"/usr/lib64",
"/usr/lib/x86_64-linux-gnu",
"/usr/lib/aarch64-linux-gnu",
];

CANDIDATES
.iter()
.find(|dir| dir_has_libkrun(dir))
.map(|dir| (*dir).to_string())
}

let prefix =
std::env::var("LIBKRUN_PREFIX").unwrap_or_else(|_| "/opt/homebrew/lib".to_string());
println!("cargo:rustc-link-search=native={prefix}");
println!("cargo:rustc-link-arg=-Wl,-rpath,{prefix}");
/// True when `dir` contains a `libkrun.so*` file (the bare `.so` link or a
/// versioned soname such as `libkrun.so.1`).
fn dir_has_libkrun(dir: &str) -> bool {
let Ok(entries) = std::fs::read_dir(Path::new(dir)) else {
return false;
};
entries.filter_map(Result::ok).any(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with("libkrun.so"))
})
}
20 changes: 12 additions & 8 deletions crates/minvmd/src/cmd/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
//! 6. With `--foreground`: stays alive until the VMM child exits, propagating
//! its exit code.
//!
//! On Linux this subcommand bails immediately with a "macOS only" error so the
//! Linux-only CI stays green.
//! Without libkrun this subcommand bails immediately with a "no libkrun" error
//! so the stock Linux CI (which has no libkrun) stays green.

use anyhow::{Result, bail};

Expand All @@ -22,18 +22,18 @@ use anyhow::{Result, bail};
/// `foreground`: if true, block until the VMM child process exits after the VM
/// is confirmed up.
pub fn run(foreground: bool) -> Result<()> {
#[cfg(target_os = "macos")]
return run_macos(foreground);
#[cfg(minvmd_libkrun)]
return run_boot(foreground);

#[cfg(not(target_os = "macos"))]
#[cfg(not(minvmd_libkrun))]
{
let _ = foreground;
bail!("`minvmd boot` is macOS-only; this Linux build is a no-op stub");
bail!("`minvmd boot` requires libkrun (macOS, or Linux with libkrun installed)");
}
}

#[cfg(target_os = "macos")]
fn run_macos(foreground: bool) -> Result<()> {
#[cfg(minvmd_libkrun)]
fn run_boot(foreground: bool) -> Result<()> {
use std::io::{BufRead, BufReader};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
Expand All @@ -45,6 +45,10 @@ fn run_macos(foreground: bool) -> Result<()> {
use crate::image::{resolve_kernel_path, resolve_rootfs_path};
use crate::state::StateDir;

// R2.4: fail fast with an actionable error if the hypervisor backend is
// unavailable (Linux: /dev/kvm). No-op on macOS.
crate::cmd::ensure_hypervisor_accessible()?;

// Fail-fast: resolve paths before spawning anything.
let _kernel = resolve_kernel_path().context("resolving kernel path")?;
let _rootfs = resolve_rootfs_path().context("resolving rootfs path")?;
Expand Down
Loading
Loading
0