Skip to content

feat(minvmd): build against a static musl libkrun - #1070

Merged
norrietaylor merged 8 commits into
mainfrom
feat/static-musl-libkrun
Jul 31, 2026
Merged

feat(minvmd): build against a static musl libkrun#1070
norrietaylor merged 8 commits into
mainfrom
feat/static-musl-libkrun

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jul 29, 2026

Copy link
Copy Markdown
Member

First step of #1065: make minvmd buildable as a self-contained static-musl binary. This does not wire it into the release yet — that is the next PR, and it is where x86_64 gets proven.

Why

minvmd is the only one of the four binaries that cannot ship to Linux users. min, mip and minimald are self-contained musl builds; minvmd is a native-glibc build that links libkrun.so and dlopens libkrunfw.so.5. release.yml states the constraint outright:

minvmd links libkrun's KVM backend dynamically, so unlike the three binaries above it can't join the static-musl cross build — it needs a native glibc build against a real libkrun.

Shipping that as-is puts ~29 MB of libraries plus RUNPATH machinery, a bin/lib sibling constraint, and a glibc floor onto users' disks. This removes the reason to.

scripts/build-libkrun-linux.sh

The Linux twin of build-libkrun-macos.sh — same vendored pin, same patch series, same trim, different link model. Three things it must do that the dylib build does not:

Add staticlib to the crate-type. Upstream declares crate-type = ["cdylib", "lib"], so cargo emits no archive to link. Applied as a guarded sed rather than a carried .patch: this is not an upstream fix but a property of how we consume the crate, and a context-free edit survives a pin bump that would reject a line-anchored patch. The guard fails loudly if upstream's declaration ever changes shape.

Merge, then localize — the order is load-bearing. libkrun.a carries its whole Rust dependency closure, including a copy of libstd that collides with minvmd's own. ld -r --whole-archive merges into a single object first; only then does objcopy cut it down to the krun_* globals. Doing it the other way round fails subtly: objcopy over an archive rewrites each member independently, so a symbol defined in one member and referenced from another gets localized at the definition and becomes unresolvable from the reference.

Drop libkrunfw entirely. It exists only to carry a bundled guest kernel that minvmd never uses — it supplies its own via ctx.set_kernel. This is what makes the whole thing possible: a static musl binary cannot dlopen, so a real libkrunfw dependency would have been fatal rather than merely wasteful. It also takes a GPL-2 kernel blob out of the shipped closure.

Build-side changes

build.rs treats a libkrun.a in the resolved prefix as the selector for a static link: it emits minvmd_libkrun_static and records no rpath, since a static link has nothing to resolve at load time. raw.rs's #[link] becomes conditional on that cfg.

Linux only, deliberately. macOS builds and ships a dylib, and quietly switching that proven path because some unrelated libkrun install happened to drop an archive in /opt/homebrew/lib would be a surprise, not a feature.

Drive-by: a latent bug in the macOS script

It ran cargo with --manifest-path from the minimal repo root. Cargo resolves .cargo/config.toml from the CWD, not from the manifest's directory — so it read ours and ignored libkrun's. At the pin, that file contains:

[target.'cfg(target_env = "musl")']
rustflags = ["--cfg", "musl_v1_2_3"]

On darwin this only drops a test runner, which is why nobody noticed. On musl it silently drops --cfg musl_v1_2_3, which is load-bearing. Fixed in both scripts so the two cannot disagree about what they compiled.

Verification

Built end-to-end on aarch64 (rust:alpine, GNU binutils 2.45.1):

check result
libkrun.a builds ✅ — cargo confirms the crate-type edit landed by warning it dropped the now-inert cdylib for a crt-static target
symbol surface after localization 67 krun_* globals, 0 non-krun_ globals
minvmd links it ✅ — 8,335,392-byte binary
file ELF 64-bit LSB executable, ARM aarch64, statically linked
ldd not a valid dynamic program — no dynamic deps at all
real backend, not the stub kvm_run, kvm_coalesced_mmio, KRUN_BLOCK_ROOT_FSTYPE all present; no stub markers; binary runs

~29 MB of shipped libraries becomes an 8 MB self-contained binary.

shellcheck clean at 0.11.0 and CI's 0.10.0. cargo clippy -p minvmd --all-targets -- -D warnings clean, and the static branch typechecks under a forced --cfg minvmd_libkrun_static.

Not proven here: x86_64, and actually booting a VM (needs KVM). Both belong in CI, in the follow-up that adds the release wiring.

Refs #1065, #980

🤖 Generated with Claude Code

Note

Build minvmd against a static musl libkrun on Linux

  • Adds scripts/build-libkrun-linux.sh to build a static libkrun.a from source at a pinned commit, applying patches, merging archive members into a single relocatable object to localize non-krun_* symbols, and staging the result into a given prefix.
  • Updates build.rs to detect libkrun.a in the prefix and emit cfg(minvmd_libkrun_static); when set, rpath emission is skipped.
  • Updates the FFI extern block in raw.rs to link krun with kind = "static" when minvmd_libkrun_static is set, falling back to dynamic linking otherwise.
  • Fixes the macOS build script to cd into the libkrun source directory so its .cargo/config.toml is respected, matching the Linux behavior.
  • Risk: static linking localizes non-krun_* symbols, which may cause conflicts if other crates in the binary also statically link libraries with overlapping symbol names.

Changes since #1070 opened

  • Added environment variable-based build requirement enforcement and static library detection to the minvmd build script [697b3a9]
  • Modified libkrun build scripts to detect and export the active rustup toolchain from the repository root before changing directories [697b3a9]
  • Replaced bash-specific indirect expansion for reading target-specific linker environment variable with eval-based assignment to a new variable, updated conditional to test the new variable, and added a comment explaining the avoidance of bash-specific substitution for POSIX sh compatibility [9338e89]
  • Added composite action for building and staging static musl libkrun.a [bc6b20e]
  • Changed release workflow to build static musl minvmd for x86_64-unknown-linux-musl and aarch64-unknown-linux-musl targets [bc6b20e]
  • Updated CI workflow to build and test static musl minvmd for x86_64-unknown-linux-musl [bc6b20e]
  • Updated nightly workflow to verify static linkage of shipped minvmd [bc6b20e]
  • Updated staging script and documentation to reflect static musl builds for Linux [bc6b20e]
  • Updated CI workflow validation checks to accept both 'statically linked' and 'static-pie linked' outputs from file(1) when verifying minvmd binary linkage [a26b89e]
  • Restricted static linking to musl targets only [4b51864]
  • Added cross-architecture build support with automatic binutils selection [4b51864]
  • Replaced eval-based environment variable reading with printenv [4b51864]
  • Added validation in scripts/build-libkrun-linux.sh to ensure the produced libkrun.a contains no undefined Rust symbols by running nm -u and filtering for Rust symbol patterns including C++ mangled symbols starting with _ZN, Rust v0 mangled symbols starting with _R, __rust* symbols, and rust_eh_personality, failing the build if any are detected [7a055e5]
  • Modified symbol extraction in scripts/build-libkrun-linux.sh to wrap the grep '^krun_' command in { grep '^krun_' || true; } when generating keep.syms from the merged object, allowing the script to continue under pipefail mode when no krun_* symbols match and letting the subsequent explicit count check emit a diagnostic [7a055e5]
  • Replaced live piped symbol verification commands strings -a "$bin" | grep -q 'kvm_run' in both the amd64 and arm64 Linux release build workflows with a two-step process that first redirects strings -a "$bin" output to a temporary file ($RUNNER_TEMP/minvmd-{amd64,arm64}.strings) and then greps that file for kvm_run [7a055e5]
  • Clarified in docs/internal/release-pipeline.md that amd64 musl builds may be reported by file(1) as either 'statically linked' or 'static-pie linked' [7a055e5]

Macroscope summarized 69f9dc6.

Summary by CodeRabbit

  • New Features
    • Linux releases now include statically linked minvmd binaries with embedded libkrun support for amd64 and arm64.
    • Added Linux microVM payloads, including initramfs, rootfs, and kernel artifacts.
  • Bug Fixes
    • Improved reliability of Linux and macOS libkrun builds.
    • Builds now validate required symbols and linkage before producing release artifacts.
  • Documentation
    • Updated release pipeline documentation to reflect the new Linux components and static packaging.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

This PR adds a Linux script and composite action for validated static musl libkrun.a builds. It updates minvmd link selection and integrates static amd64 and arm64 artifacts into CI, nightly checks, releases, and staging. macOS Cargo builds now run from the fetched source directory.

Changes

libkrun build and linking

Layer / File(s) Summary
Build validated Linux static archive
scripts/build-libkrun-linux.sh
Fetches the pinned libkrun revision, builds a musl static archive, localizes non-API symbols, validates exports, and stages the archive.
Select static or shared minvmd linking
crates/minvmd/build.rs, crates/minvmd/src/krun/raw.rs
Detects static and shared libraries, enforces MINVMD_REQUIRE_LIBKRUN, emits minvmd_libkrun_static, and selects the matching linker directive.
Build and publish static Linux artifacts
.github/actions/build-libkrun-static-linux/action.yml, .github/workflows/ci-linux-kvm.yml, .github/workflows/nightly.yml, .github/workflows/release.yml
Builds, caches, verifies, tests, and publishes static musl minvmd artifacts for Linux amd64 and arm64.
Resolve macOS Cargo configuration
scripts/build-libkrun-macos.sh
Resolves the active toolchain and runs the locked Cargo build from the fetched libkrun working directory.
Stage Linux minvmd and guest artifacts
docs/internal/release-pipeline.md, scripts/stage-release.sh
Documents and stages Linux minvmd and architecture-specific guest artifacts without a separate Linux libkrun component.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • gominimal/minimal#1065 — The PR implements the issue’s static-musl minvmd approach, including static libkrun linking and release integration.

Possibly related PRs

  • gominimal/minimal#1104 — Modifies the same static libkrun build scripts, workflows, linker configuration, and release staging.
  • gominimal/minimal#988 — Covers the earlier dynamic libkrun packaging and workflow paths that this PR replaces with static musl artifacts.

Suggested reviewers: twitchyliquid64, bryan-minimal

Poem

A rabbit packed libkrun tight,
With musl links sealed from sight.
Minvmd checks the archive’s name,
CI guards the static flame.
Releases hop in both builds bright.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: static musl linking for minvmd against libkrun.
Description check ✅ Passed The description explains the purpose, implementation, testing evidence, references, limitations, and documentation impact in sufficient detail.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/static-musl-libkrun

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

norrietaylor and others added 2 commits July 30, 2026 12:13
minvmd is the only one of the four binaries that cannot ship to Linux
users. min, mip and minimald are self-contained musl builds; minvmd is a
native-glibc build that links libkrun.so and dlopens libkrunfw.so.5, so
shipping it means ~29 MB of libraries plus RUNPATH machinery, a bin/lib
sibling constraint and a glibc floor on users' disks. release.yml states
the constraint outright: "it can't join the static-musl cross build".

This removes the constraint. It does not yet wire it into the release —
that is the next step, and it is where x86_64 gets proven.

scripts/build-libkrun-linux.sh is the Linux twin of the macOS script:
same vendored pin, same patch series, same trim, different link model.
Three things it has to do that the dylib build does not:

- Add `staticlib` to libkrun's crate-type. Upstream declares
  ["cdylib", "lib"], so cargo emits no archive to link. Applied as a
  guarded sed rather than a carried patch: it is not an upstream fix but
  a property of how we consume the crate, and it survives a pin bump
  that would reject a line-anchored patch.
- Merge THEN localize. libkrun.a carries its whole Rust dependency
  closure including a copy of libstd, which collides with minvmd's own.
  `ld -r --whole-archive` into a single object first, and only then
  objcopy down to the krun_* globals: objcopy over an archive rewrites
  each member independently, so localizing first would break references
  that cross members.
- Drop libkrunfw entirely. It exists only to carry a bundled guest
  kernel that minvmd never uses (it supplies its own via ctx.set_kernel).
  That is what makes this possible at all — a static musl binary cannot
  dlopen — and it takes a GPL-2 kernel blob out of the shipped closure.

build.rs treats a libkrun.a in the prefix as the selector for a static
link, emitting minvmd_libkrun_static and recording no rpath (a static
link has nothing to resolve at load time). raw.rs's `#[link]` becomes
conditional on that cfg. Linux only: macOS builds and ships a dylib, and
silently switching that proven path because some unrelated libkrun
install dropped an archive in /opt/homebrew/lib would be a surprise.

Also fixes a latent bug in the macOS script. It ran cargo with
--manifest-path from the minimal repo root, but cargo resolves
.cargo/config.toml from the CWD, so it read ours and ignored libkrun's.
On darwin that only drops a test runner; libkrun's config also carries

    [target.'cfg(target_env = "musl")']
    rustflags = ["--cfg", "musl_v1_2_3"]

which is load-bearing for the musl build. Fixed in both scripts so they
cannot disagree about what they compiled.

Verified end-to-end on aarch64 (rust:alpine, binutils 2.45.1):

- libkrun.a builds; cargo confirms the crate-type edit landed by warning
  that it dropped the now-inert cdylib for a crt-static target
- 67 krun_* symbols kept global, 0 non-krun_ globals left after
  localization
- minvmd links it into an 8,335,392-byte ELF that `file` reports as
  "statically linked" and ldd rejects as "not a valid dynamic program"
- the real KVM backend is in there, not the stub: kvm_run,
  kvm_coalesced_mmio and KRUN_BLOCK_ROOT_FSTYPE are all present, and
  the binary runs

Not yet proven: x86_64, and booting a VM (needs KVM). Both belong in CI.

Refs #1065, #980

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A musl-native host (Alpine) builds its own triple and needs no linker
override, which is how the script was verified. A glibc host with
musl-tools — every Ubuntu runner and any dev box — needs musl-gcc, and
without it the cargo build fails with a bare linker error that says
nothing about musl-tools.

Default cargo's documented `CARGO_TARGET_<TRIPLE>_LINKER` when the
target is not the host triple, and fail with an actionable message when
musl-gcc is missing. An explicit value always wins, so a caller with its
own toolchain is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@norrietaylor
norrietaylor force-pushed the feat/static-musl-libkrun branch from 269ecf4 to 69f9dc6 Compare July 30, 2026 19:14
norrietaylor added a commit that referenced this pull request Jul 30, 2026
Completes the Linux half of #980. minvmd was the only one of the four
binaries not staged for Linux, and the only one that could not be: it
was a native-glibc build linking libkrun.so and dlopening libkrunfw.so.5,
so shipping it meant ~29 MB of libraries plus RUNPATH machinery, a
bin/lib sibling constraint and a glibc floor on users' disks.

Built on #1070, which made minvmd linkable against a static libkrun.

release.yml: both Linux jobs now build a static libkrun.a from the
vendored pin and link minvmd against it as musl, alongside the three
binaries that were already musl. arm64 gains a minvmd artifact for the
first time — the dynamic build needed a native glibc host per arch and
only amd64 had one, a constraint a static build does not have. The
libkrun build rides a new build-libkrun-static-linux composite, modelled
on setup-libkrun-macos: pin-keyed cache, build-on-miss, and a verify
step that re-asserts the API floor and the symbol surface on a
cache-restored archive.

Both jobs assert the built binary is `statically linked` and actually
contains the KVM backend. Neither is implied by a successful build: when
build.rs finds no libkrun it emits a stub that compiles and links
perfectly well, and would ship as a binary that bails at VM boot.

stage-release.sh: Linux gains bin/minvmd plus the guest payload
(data/{vmlinuz,rootfs.img,initramfs.cpio}) for both arches. All six
guest artifacts were already produced for both arches; only the rows
were missing. No lib/ component and no RUNPATH rewrite — that asymmetry
with darwin is the entire point.

nightly.yml: smoke-linux-kvm no longer materializes a libkrun prefix or
sets LD_LIBRARY_PATH, because the shipped binary needs neither. It
asserts that instead, so a regression to a dynamic build fails with a
clear message rather than a loader error mid-boot. The userns sysctl
stays — its justification cited the removed materialize step, but the
sandboxed build the e2e drives needs it independently.

setup-libkrun-linux is untouched and still used by ci-linux-kvm and
nightly-tests, which build against the dynamic upstream package.

Verified: workflow and composite YAML parse; shellcheck clean; a
dry-run stage against stub artifacts emits all eight new Linux rows with
correct dests, and the arm64 guest artifacts dedupe with darwin's as
intended.

Not verified locally: the builds themselves. x86_64 in particular has
never been exercised — the static-musl proof in #1070 is aarch64-only,
and this is the lane that settles it.

Refs #980, #1065

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minvmd/build.rs`:
- Around line 97-105: Add VM integration coverage for the minvmd_libkrun_static
configuration that builds minvmd with static libkrun detection and boots a VM
through the affected runtime path. Integrate the test with the existing VM test
harness, then run the applicable just e2e and/or just test-vm commands to verify
static-link boot behavior before enabling this mode.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 01690eec-5e18-4e61-9504-cc36c02e95d0

📥 Commits

Reviewing files that changed from the base of the PR and between a6b8191 and 269ecf4.

📒 Files selected for processing (4)
  • crates/minvmd/build.rs
  • crates/minvmd/src/krun/raw.rs
  • scripts/build-libkrun-linux.sh
  • scripts/build-libkrun-macos.sh

Comment thread crates/minvmd/build.rs
Comment on lines +97 to +105
// A `libkrun.a` in the prefix selects the static link. Linux only: the
// macOS pipeline builds and ships a dylib, and quietly switching that
// proven path because some other libkrun install happened to drop an
// archive in /opt/homebrew/lib would be a surprise, not a feature.
if target_os == "linux" && Path::new(&prefix).join(STATIC_LIB).exists() {
// No rpath: a static link resolves at build time, so there is
// nothing for the loader to search for. The `#[link]` kind is
// flipped in src/krun/raw.rs by this cfg.
println!("cargo::rustc-cfg=minvmd_libkrun_static");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Add static-link VM integration coverage before enabling this mode.

This changes minvmd’s runtime link closure and removes loader-based resolution, but the PR explicitly defers VM boot testing. Add a static-link build-and-boot integration test and run just e2e and/or just test-vm before merging.

As per coding guidelines, “When changing VM or daemon behavior, add or update the appropriate integration tests and run just e2e and/or just test-vm.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minvmd/build.rs` around lines 97 - 105, Add VM integration coverage
for the minvmd_libkrun_static configuration that builds minvmd with static
libkrun detection and boots a VM through the affected runtime path. Integrate
the test with the existing VM test harness, then run the applicable just e2e
and/or just test-vm commands to verify static-link boot behavior before enabling
this mode.

Source: Coding guidelines

@twitchyliquid64 twitchyliquid64 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The object/symbol surgery to make this work feels very fragile, in particular coalescing the rust internal symbols between libkrun and the minvmd binary. Down to give it a go, i guess time will tell how fragile this is.

 missing libkrun

Two fixes, both about a build that should fail doing something worse.

**The toolchain pin escaped with the CWD.** Moving cargo into the fetched
libkrun tree fixed .cargo/config.toml resolution, but rustup resolves
rust-toolchain.toml from the CWD too. Leaving the repo silently fell back
to the DEFAULT toolchain — and in CI that is not the toolchain
`rustup target add` installed the musl target into, so the release job
died with:

    error[E0463]: can't find crate for `core`
    note: the `x86_64-unknown-linux-musl` target may not be installed

--manifest-path had the right toolchain and the wrong cargo config; the
cd had the right config and the wrong toolchain. Resolve the pin where
rust-toolchain.toml still applies and export RUSTUP_TOOLCHAIN across the
cd, so the build gets both.

Not merely convenience for the static build: libkrun.a is a Rust
staticlib linked into a Rust binary, and Rust has no stable ABI. libkrun
and minvmd must come out of the same rustc. Applied to the macOS script
too — its boundary is a C-ABI dylib so it is less exposed, but the two
scripts should not disagree about what they compiled.

**A missing libkrun produced a stub instead of an error.** When build.rs
finds no libkrun it builds a runtime-bailing stub. That is deliberate —
it keeps stock Linux CI green — but it means a build that SHOULD have
linked libkrun and didn't still succeeds, and ships a binary that
compiles, links, and then bails at VM boot. release.yml greps the built
binary for proof, but the build itself was happy either way.

MINVMD_REQUIRE_LIBKRUN closes that. `static` demands a libkrun.a and a
static link; any other non-empty value demands some libkrun. Unset (or
`0`) keeps stub-on-miss, so no existing build changes. The release lanes
opt in, turning a silent bad binary into a build error that names the
prefix it looked in and the script that fills it.

Verified all four paths: require=static with no libkrun and with only a
libkrun.so both panic with actionable messages; unset still builds the
stub; require=1 accepts a dynamic libkrun.

Refs #1065

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
norrietaylor added a commit that referenced this pull request Jul 30, 2026
Completes the Linux half of #980. minvmd was the only one of the four
binaries not staged for Linux, and the only one that could not be: it
was a native-glibc build linking libkrun.so and dlopening libkrunfw.so.5,
so shipping it meant ~29 MB of libraries plus RUNPATH machinery, a
bin/lib sibling constraint and a glibc floor on users' disks.

Built on #1070, which made minvmd linkable against a static libkrun.

release.yml: both Linux jobs now build a static libkrun.a from the
vendored pin and link minvmd against it as musl, alongside the three
binaries that were already musl. arm64 gains a minvmd artifact for the
first time — the dynamic build needed a native glibc host per arch and
only amd64 had one, a constraint a static build does not have. The
libkrun build rides a new build-libkrun-static-linux composite, modelled
on setup-libkrun-macos: pin-keyed cache, build-on-miss, and a verify
step that re-asserts the API floor and the symbol surface on a
cache-restored archive.

Both jobs assert the built binary is `statically linked` and actually
contains the KVM backend. Neither is implied by a successful build: when
build.rs finds no libkrun it emits a stub that compiles and links
perfectly well, and would ship as a binary that bails at VM boot.

stage-release.sh: Linux gains bin/minvmd plus the guest payload
(data/{vmlinuz,rootfs.img,initramfs.cpio}) for both arches. All six
guest artifacts were already produced for both arches; only the rows
were missing. No lib/ component and no RUNPATH rewrite — that asymmetry
with darwin is the entire point.

nightly.yml: smoke-linux-kvm no longer materializes a libkrun prefix or
sets LD_LIBRARY_PATH, because the shipped binary needs neither. It
asserts that instead, so a regression to a dynamic build fails with a
clear message rather than a loader error mid-boot. The userns sysctl
stays — its justification cited the removed materialize step, but the
sandboxed build the e2e drives needs it independently.

setup-libkrun-linux is untouched and still used by ci-linux-kvm and
nightly-tests, which build against the dynamic upstream package.

Verified: workflow and composite YAML parse; shellcheck clean; a
dry-run stage against stub artifacts emits all eight new Linux rows with
correct dests, and the arm64 guest artifacts dedupe with darwin's as
intended.

Not verified locally: the builds themselves. x86_64 in particular has
never been exercised — the static-musl proof in #1070 is aarch64-only,
and this is the lane that settles it.

Refs #980, #1065

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r sh

The musl-linker default used bash's ${!VAR} indirect expansion, so
running the script under any POSIX shell died before it built anything:

    scripts/build-libkrun-linux.sh: line 130: syntax error: bad substitution

CI never saw it — Ubuntu runners have bash and the shebang picks it up.
But Alpine, the natural place to build a musl artifact, ships no bash at
all, and `sh scripts/build-libkrun-linux.sh` is a reasonable way to
invoke it. Nothing else in the script needs bash; this was one line.

`eval` instead. `set -o pipefail` stays: it is load-bearing here, and
busybox ash supports it even though POSIX does not require it.

Found by running the script the way a user might, in a rust:alpine
container, rather than only the way CI does.

Re-verified end to end on aarch64 after the fix, under `sh`: the archive
builds (67 krun_* symbols kept, 0 leaked), MINVMD_REQUIRE_LIBKRUN=static
correctly refuses an empty prefix, and minvmd links to an 8,138,736-byte
binary that `file` reports as statically linked.

Refs #1065

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
norrietaylor added a commit that referenced this pull request Jul 30, 2026
Completes the Linux half of #980. minvmd was the only one of the four
binaries not staged for Linux, and the only one that could not be: it
was a native-glibc build linking libkrun.so and dlopening libkrunfw.so.5,
so shipping it meant ~29 MB of libraries plus RUNPATH machinery, a
bin/lib sibling constraint and a glibc floor on users' disks.

Built on #1070, which made minvmd linkable against a static libkrun.

release.yml: both Linux jobs now build a static libkrun.a from the
vendored pin and link minvmd against it as musl, alongside the three
binaries that were already musl. arm64 gains a minvmd artifact for the
first time — the dynamic build needed a native glibc host per arch and
only amd64 had one, a constraint a static build does not have. The
libkrun build rides a new build-libkrun-static-linux composite, modelled
on setup-libkrun-macos: pin-keyed cache, build-on-miss, and a verify
step that re-asserts the API floor and the symbol surface on a
cache-restored archive.

Both jobs assert the built binary is `statically linked` and actually
contains the KVM backend. Neither is implied by a successful build: when
build.rs finds no libkrun it emits a stub that compiles and links
perfectly well, and would ship as a binary that bails at VM boot.

stage-release.sh: Linux gains bin/minvmd plus the guest payload
(data/{vmlinuz,rootfs.img,initramfs.cpio}) for both arches. All six
guest artifacts were already produced for both arches; only the rows
were missing. No lib/ component and no RUNPATH rewrite — that asymmetry
with darwin is the entire point.

nightly.yml: smoke-linux-kvm no longer materializes a libkrun prefix or
sets LD_LIBRARY_PATH, because the shipped binary needs neither. It
asserts that instead, so a regression to a dynamic build fails with a
clear message rather than a loader error mid-boot. The userns sysctl
stays — its justification cited the removed materialize step, but the
sandboxed build the e2e drives needs it independently.

setup-libkrun-linux is untouched and still used by ci-linux-kvm and
nightly-tests, which build against the dynamic upstream package.

Verified: workflow and composite YAML parse; shellcheck clean; a
dry-run stage against stub artifacts emits all eight new Linux rows with
correct dests, and the arm64 guest artifacts dedupe with darwin's as
intended.

Not verified locally: the builds themselves. x86_64 in particular has
never been exercised — the static-musl proof in #1070 is aarch64-only,
and this is the lane that settles it.

Refs #980, #1065

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minvmd/build.rs`:
- Around line 95-111: Update the has_static calculation in the build script to
require both a Linux target and CARGO_CFG_TARGET_ENV equal to musl before
selecting STATIC_LIB. Ensure non-musl Linux builds do not enable
minvmd_libkrun_static, allowing the existing shared-library or
requirement-validation path to handle them.

In `@scripts/build-libkrun-linux.sh`:
- Line 1: Update scripts/build-libkrun-linux.sh to use a POSIX shebang and
remove the Bash-only pipefail dependency; replace every pipefail-dependent
pipeline with POSIX-compatible temporary-file handling and explicit
command-status checks, preserving the existing behavior and cleanup. Ensure the
script runs successfully under the POSIX shells referenced by its validation
guidance.
- Around line 44-53: Update the archive-rewrite flow in
scripts/build-libkrun-linux.sh to select binutils based on the resolved TARGET
rather than the build host. Ensure both ld -r and objcopy use the
target-specific tool or equivalent cross-binutils prefix, including when TARGET
is supplied explicitly for cross-compilation, while preserving native host
defaults.
- Around line 122-132: Replace the eval-based lookup in the LINKER_VAR handling
with POSIX-compatible dynamic environment access that does not reparse
TARGET-derived text as shell syntax. Preserve the existing empty-value check and
subsequent musl-gcc fallback/export behavior, using the validated target-derived
variable name only for indirect lookup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 70e57fa7-7960-45ed-be00-7929db211b95

📥 Commits

Reviewing files that changed from the base of the PR and between 269ecf4 and 9338e89.

📒 Files selected for processing (4)
  • crates/minvmd/build.rs
  • crates/minvmd/src/krun/raw.rs
  • scripts/build-libkrun-linux.sh
  • scripts/build-libkrun-macos.sh

Comment thread crates/minvmd/build.rs Outdated
Comment thread scripts/build-libkrun-linux.sh
Comment thread scripts/build-libkrun-linux.sh Outdated
Comment thread scripts/build-libkrun-linux.sh
norrietaylor and others added 2 commits July 30, 2026 17:19
* feat(release): ship minvmd to Linux as a static musl binary

Completes the Linux half of #980. minvmd was the only one of the four
binaries not staged for Linux, and the only one that could not be: it
was a native-glibc build linking libkrun.so and dlopening libkrunfw.so.5,
so shipping it meant ~29 MB of libraries plus RUNPATH machinery, a
bin/lib sibling constraint and a glibc floor on users' disks.

Built on #1070, which made minvmd linkable against a static libkrun.

release.yml: both Linux jobs now build a static libkrun.a from the
vendored pin and link minvmd against it as musl, alongside the three
binaries that were already musl. arm64 gains a minvmd artifact for the
first time — the dynamic build needed a native glibc host per arch and
only amd64 had one, a constraint a static build does not have. The
libkrun build rides a new build-libkrun-static-linux composite, modelled
on setup-libkrun-macos: pin-keyed cache, build-on-miss, and a verify
step that re-asserts the API floor and the symbol surface on a
cache-restored archive.

Both jobs assert the built binary is `statically linked` and actually
contains the KVM backend. Neither is implied by a successful build: when
build.rs finds no libkrun it emits a stub that compiles and links
perfectly well, and would ship as a binary that bails at VM boot.

stage-release.sh: Linux gains bin/minvmd plus the guest payload
(data/{vmlinuz,rootfs.img,initramfs.cpio}) for both arches. All six
guest artifacts were already produced for both arches; only the rows
were missing. No lib/ component and no RUNPATH rewrite — that asymmetry
with darwin is the entire point.

nightly.yml: smoke-linux-kvm no longer materializes a libkrun prefix or
sets LD_LIBRARY_PATH, because the shipped binary needs neither. It
asserts that instead, so a regression to a dynamic build fails with a
clear message rather than a loader error mid-boot. The userns sysctl
stays — its justification cited the removed materialize step, but the
sandboxed build the e2e drives needs it independently.

setup-libkrun-linux is untouched and still used by ci-linux-kvm and
nightly-tests, which build against the dynamic upstream package.

Verified: workflow and composite YAML parse; shellcheck clean; a
dry-run stage against stub artifacts emits all eight new Linux rows with
correct dests, and the arm64 guest artifacts dedupe with darwin's as
intended.

Not verified locally: the builds themselves. x86_64 in particular has
never been exercised — the static-musl proof in #1070 is aarch64-only,
and this is the lane that settles it.

Refs #980, #1065

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(ci): boot the STATIC minvmd in the KVM lane, not a dynamic one

ci-linux-kvm is the only pre-merge lane that boots a real microVM, and
it was booting the wrong binary. It built minvmd against the dynamic
upstream libkrun package, shipped that .so in the testbed, and resolved
it at runtime via LD_LIBRARY_PATH. Meanwhile Linux now ships a
static-musl minvmd.

That left the shipped configuration first exercised by nightly's
smoke-linux-kvm — after merge. Nothing pre-merge compiled
build-libkrun-linux.sh at all, and nothing anywhere had ever BOOTED a
statically linked minvmd: release.yml only asserts `file` says static
and `strings` finds kvm_run, which proves the archive is well-formed and
the backend is linked, not that libkrun still works.

That gap matters here more than usual. libkrun.a is produced by merging
a whole Rust dependency closure with `ld -r` and then objcopy-localizing
everything outside the krun_* API — precisely the kind of transform that
links clean and misbehaves at runtime (two libstd copies, TLS, the panic
runtime). A symbol check cannot catch that; a boot can.

So this lane now builds libkrun statically from the vendored pin, builds
minvmd for x86_64-unknown-linux-musl, and boots that. The nextest
archive moves to the same target — the harnesses link libkrun too, and a
native-gnu archive would test a different binary than the one shipped.

Falls out of the change: no libkrun-prefix.tgz in the testbed, no
$HOME/.krun unpack, no LIBKRUN_PREFIX or LD_LIBRARY_PATH in the test
job. The binary carries its own libkrun.

Three assertions rather than assumptions, because a silent regression to
a dynamic link would quietly restore the gap this closes:
MINVMD_REQUIRE_LIBKRUN=static on both cargo invocations (a build.rs that
cannot find libkrun.a is now an error, not a stub), and a `file ... |
grep statically linked` check at each end of the artifact handoff.

Two path-filter fixes: vendor/libkrun/** was never listed, so a pin or
patch bump could not re-run the lane that now builds from it; and the
composite entry follows the lane onto build-libkrun-static-linux.

The dynamic path keeps its coverage in nightly-tests.yml, which still
builds against the upstream package — it remains the dev/`just up-kvm`
configuration, just no longer the one CI proves before merge.

Refs #980, #1065

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… minvmd

x86_64-unknown-linux-musl links a static PIE, which file(1) reports as
"static-pie linked", not "statically linked". The assertion grepped only for
the latter, so build-release-linux-amd64 failed on a binary that was correctly
static while the arm64 job passed: aarch64-unknown-linux-musl emits a plain
non-PIE static executable.

Neither form has a PT_INTERP or a DT_NEEDED, so both satisfy what the check is
actually for. Accept either spelling at all five assertion sites; the arm64 and
testbed ones pass today only by accident of that target's current default and
would break the same way if it ever switches to static-pie.

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

Copy link
Copy Markdown
Member Author

The object/symbol surgery to make this work feels very fragile, in particular coalescing the rust internal symbols between libkrun and the minvmd binary. Down to give it a go, i guess time will tell how fragile this is.

I agree, fragile indeed. It is cheap to try but if it means that dev, updates become brittle I will walk it back.

Review follow-ups on #1070.

build.rs gated `has_static` on `target_os == "linux"` alone, so a default
*-linux-gnu build pointed at a staged prefix selected kind = "static" and would
try to link a musl staticlib into a glibc binary. Worse, it satisfied
MINVMD_REQUIRE_LIBKRUN=static, whose entire job is to prove the link model we
ship. Require target_env == "musl" too, and report that mismatch distinctly
from a genuinely missing archive, since "no libkrun.a in <prefix>" sends the
reader hunting for a file that is sitting right there.

build-libkrun-linux.sh looked up the cargo linker override with `eval` over a
variable name derived from $TARGET, which is only suffix-validated, so a
crafted triple could inject shell syntax into the build. `printenv` reads the
variable without a second round of expansion, and is not a bashism either,
which is what the eval was working around in the first place.

The archive rewrite ran the HOST ld/nm/objcopy/ar unconditionally. That is
correct for CI, where each arch builds on its own runner, but silently wrong
for the cross-build the usage text advertised: the host linker cannot merge
foreign objects. Prefer target-prefixed binutils when the arches differ, fail
with an actionable message when none are installed, and correct the two
comments that overstated the cross-build support.

Verified on aarch64: the script still stages the archive end-to-end (67 krun_*
symbols) and passes shellcheck; a gnu build with a staged archive now fails
with the new message instead of attempting a static link; the musl build still
produces a statically linked minvmd.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/build-libkrun-linux.sh (1)

161-165: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make the toolchain probe non-fatal.

Line 162 assigns from a command substitution that runs rustup show active-toolchain. Under set -euo pipefail the assignment inherits the pipeline status, so a non-zero rustup exit aborts the script. rustup show active-toolchain exits non-zero when no toolchain is active or installed, and the redirect to /dev/null hides the message but not the status. The surrounding code intends a soft fallback: line 163 guards on a non-empty value and line 164 prints <rustup default>.

Add an explicit fallback so the probe cannot end the build.

🛠️ Proposed fix
-  RUSTUP_TOOLCHAIN="$(cd "$ROOT" && rustup show active-toolchain 2>/dev/null | cut -d' ' -f1)"
+  RUSTUP_TOOLCHAIN="$( (cd "$ROOT" && rustup show active-toolchain 2>/dev/null || true) | cut -d' ' -f1)"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build-libkrun-linux.sh` around lines 161 - 165, Make the rustup probe
in the RUSTUP_TOOLCHAIN assignment non-fatal under set -euo pipefail by
explicitly handling a failed rustup show active-toolchain command and producing
an empty value. Preserve the existing non-empty export guard and <rustup
default> logging fallback.
🧹 Nitpick comments (1)
crates/minvmd/build.rs (1)

146-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reject unrecognized MINVMD_REQUIRE_LIBKRUN values.

Line 148 returns for an empty value or 0, and line 161 handles static. Any other non-empty value falls through to the prefix-presence check only. A typo such as statc therefore satisfies the requirement while the build links libkrun dynamically. The release and CI workflows use this variable as the proof of the shipped link model, so a silent downgrade defeats it.

Accept an explicit value set and panic on anything else.

♻️ Proposed refactor
     let requirement = std::env::var("MINVMD_REQUIRE_LIBKRUN").unwrap_or_default();
     if requirement.is_empty() || requirement == "0" {
         return;
     }
+    if !matches!(requirement.as_str(), "1" | "static") {
+        panic!(
+            "MINVMD_REQUIRE_LIBKRUN={requirement} is not a recognized value; use `1` (any \
+             libkrun), `static` (require {STATIC_LIB}), or `0`/unset to allow the stub."
+        );
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minvmd/build.rs` around lines 146 - 150, Update enforce_requirement to
accept only the supported MINVMD_REQUIRE_LIBKRUN values: empty or "0" as no
requirement, and "static" for the static-link requirement. For any other
non-empty value, reject it immediately by panicking instead of continuing to the
prefix-presence check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Around line 215-228: The libkrun symbol checks in both Linux verification
steps can fail under pipefail because grep terminates strings early; materialize
strings -a "$bin" into a temporary file under $RUNNER_TEMP, then run grep -q
'kvm_run' against that file. Apply this change at .github/workflows/release.yml
lines 215-228 and 330-338, preserving the existing failure messages and
validation flow.

In `@docs/internal/release-pipeline.md`:
- Around line 42-44: Update the release-pipeline documentation’s linkage
assertion to mention both accepted file(1) descriptions: “statically linked” and
“static-pie linked,” matching the workflow check while preserving the existing
KVM-backend assertion.

In `@scripts/build-libkrun-linux.sh`:
- Around line 220-223: Update the symbol extraction pipeline before the count
check, using the existing keep.syms generation around nm and grep, so grep
returning no matches is tolerated under pipefail. Preserve the empty-allowlist
validation and ensure the explanatory “no krun_* symbols found” error at the
subsequent count check is the reported failure.

---

Outside diff comments:
In `@scripts/build-libkrun-linux.sh`:
- Around line 161-165: Make the rustup probe in the RUSTUP_TOOLCHAIN assignment
non-fatal under set -euo pipefail by explicitly handling a failed rustup show
active-toolchain command and producing an empty value. Preserve the existing
non-empty export guard and <rustup default> logging fallback.

---

Nitpick comments:
In `@crates/minvmd/build.rs`:
- Around line 146-150: Update enforce_requirement to accept only the supported
MINVMD_REQUIRE_LIBKRUN values: empty or "0" as no requirement, and "static" for
the static-link requirement. For any other non-empty value, reject it
immediately by panicking instead of continuing to the prefix-presence check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a49d881b-1fcc-4255-a03c-e130eeaf6ac7

📥 Commits

Reviewing files that changed from the base of the PR and between 9338e89 and 4b51864.

📒 Files selected for processing (8)
  • .github/actions/build-libkrun-static-linux/action.yml
  • .github/workflows/ci-linux-kvm.yml
  • .github/workflows/nightly.yml
  • .github/workflows/release.yml
  • crates/minvmd/build.rs
  • docs/internal/release-pipeline.md
  • scripts/build-libkrun-linux.sh
  • scripts/stage-release.sh

Comment thread .github/workflows/release.yml
Comment thread docs/internal/release-pipeline.md Outdated
Comment thread scripts/build-libkrun-linux.sh
…l traps

Pins the property the #1070 review flagged as fragile ("coalescing the rust
internal symbols between libkrun and the minvmd binary"), and fixes the
pipefail hazards CodeRabbit found in the verification steps.

libkrun.a carries its own copy of libstd, localized so only the 67 krun_*
entry points stay global. The other half of that isolation was never checked:
if any Rust symbol is left UNDEFINED, the final link resolves it against
MINVMD's libstd instead, coalescing two independent Rust runtimes into one
binary. Assert there are none. A clean archive imports only C, musl libc plus
the _Unwind_* ABI, which is shared deliberately (one unwinder per process is
correct; two would be the bug). Measured on the current pin: 221 libc, 14
_Unwind_*, 0 Rust. Worth asserting rather than assuming, because the failure is
invisible at build time and only surfaces at VM boot, and because `ld -r` plus
`objcopy --keep-global-symbols` is binutils behaviour rather than a stability
contract, so a toolchain upgrade is the plausible regression path.

Three pipefail traps, all the same shape the script already documents for nm:

- release.yml piped `strings -a` over a ~10 MB binary into `grep -q` in both
  Linux jobs. `grep -q` exits at its first match, SIGPIPEs `strings`, and
  pipefail turns the dead producer into a step failure, so a PASSING check can
  fail a good release. Timing-dependent, which is worse than deterministic.
  Materialize the dump, then grep the file.
- keep.syms ended in `grep '^krun_'`, which exits 1 on no match and aborted the
  script before the "no krun_* symbols found" error could explain why.

Also corrects release-pipeline.md, which still quoted `statically linked` as
the sole accepted spelling after the check learned `static-pie linked`.

Verified on aarch64: full script run stages the archive (67 symbols) with the
new assertion passing, shellcheck clean, and the detector fires on synthetic
_ZN/_R/__rust_ input while staying silent on libc and _Unwind_*.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@norrietaylor
norrietaylor merged commit 749515f into main Jul 31, 2026
41 checks passed
@norrietaylor
norrietaylor deleted the feat/static-musl-libkrun branch July 31, 2026 05:02
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.

2 participants