mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42d15face5 | ||
|
|
53dd20b7f0 | ||
|
|
ac08b106bc | ||
|
|
775592482b | ||
|
|
f66024bfea | ||
|
|
8c202c2a68 | ||
|
|
612826cca8 | ||
|
|
561a241e09 | ||
|
|
d14d8c07bd | ||
|
|
a3b4dca2d9 | ||
|
|
3844038d3a | ||
|
|
6a631a3707 | ||
|
|
ff73624891 | ||
|
|
f963578152 | ||
|
|
92ff76a1b6 | ||
|
|
84ccff0157 | ||
|
|
e075d52f91 |
No files matched your search
@@ -38,6 +38,60 @@ jobs:
|
|||||||
- name: cargo test
|
- name: cargo test
|
||||||
run: cargo test
|
run: cargo test
|
||||||
|
|
||||||
|
macos:
|
||||||
|
name: macos — fmt, clippy, test, probe
|
||||||
|
runs-on: macos-26
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy, rustfmt
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
# Homebrew refuses formulae from third-party taps until the tap
|
||||||
|
# is trusted once; libkrun's dependencies come from the krun tap.
|
||||||
|
- name: Trust the krun tap
|
||||||
|
run: brew trust --tap libkrun/krun
|
||||||
|
|
||||||
|
# The engine is dlopen'd from the brew kegs at runtime; e2fsprogs
|
||||||
|
# supplies mke2fs and libarchive its tar path.
|
||||||
|
- name: Install the engine and the image tooling
|
||||||
|
run: brew install libkrun/homebrew-krun/libkrun e2fsprogs libarchive
|
||||||
|
|
||||||
|
- name: cargo fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: cargo clippy
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: cargo test
|
||||||
|
run: cargo test
|
||||||
|
|
||||||
|
- name: Build the CLI
|
||||||
|
run: cargo build --release -p ecrou-cli
|
||||||
|
|
||||||
|
# HVF grants VM creation only to binaries carrying the hypervisor
|
||||||
|
# entitlement on current macOS; packaging signs the same way.
|
||||||
|
- name: Sign the binary with the hypervisor entitlement
|
||||||
|
run: codesign --force --sign - --entitlements packaging/homebrew/ecrou.entitlements target/release/ecrou
|
||||||
|
|
||||||
|
# GitHub's macOS runners expose no Hypervisor.framework (they run
|
||||||
|
# without nested virtualization), so nothing boots here — the
|
||||||
|
# boot e2e lives on Linux (the snap e2e) and on real Macs. The
|
||||||
|
# probe must still behave: the engine loads from the krun tap,
|
||||||
|
# the tools are found, and the HVF absence is reported as that.
|
||||||
|
- name: Probe
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
out="$(./target/release/ecrou probe || true)"
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
grep -q "libkrun: ok" <<<"$out"
|
||||||
|
grep -q "e2fsprogs: ok" <<<"$out"
|
||||||
|
grep -q "no Hypervisor.framework support" <<<"$out"
|
||||||
|
|
||||||
t6:
|
t6:
|
||||||
name: T6 strace audit
|
name: T6 strace audit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -62,6 +62,121 @@ jobs:
|
|||||||
name: ${{ matrix.artifact }}
|
name: ${{ matrix.artifact }}
|
||||||
path: ${{ steps.build.outputs.snap }}
|
path: ${{ steps.build.outputs.snap }}
|
||||||
|
|
||||||
|
# The macos distribution is a signed tarball, not a store release:
|
||||||
|
# brew taps install from it, and the entitlement signature rides in
|
||||||
|
# the artifact.
|
||||||
|
build-macos:
|
||||||
|
needs: gate
|
||||||
|
if: needs.gate.outputs.release == 'true'
|
||||||
|
runs-on: macos-26
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Stamp the tag version into the manifests
|
||||||
|
run: |
|
||||||
|
v="${GITHUB_REF_NAME#v}"
|
||||||
|
sed -i '' "s/^version = \".*\"/version = \"$v\"/" Cargo.toml
|
||||||
|
grep -H '^version' Cargo.toml
|
||||||
|
|
||||||
|
- name: Build the CLI
|
||||||
|
run: cargo build --release -p ecrou-cli
|
||||||
|
|
||||||
|
- name: Sign the binary with the hypervisor entitlement
|
||||||
|
run: codesign --force --sign - --entitlements packaging/homebrew/ecrou.entitlements target/release/ecrou
|
||||||
|
|
||||||
|
- name: Package
|
||||||
|
run: |
|
||||||
|
v="${GITHUB_REF_NAME#v}"
|
||||||
|
tar -czf "ecrou-v$v-darwin-arm64.tar.gz" -C target/release ecrou
|
||||||
|
shasum -a 256 "ecrou-v$v-darwin-arm64.tar.gz" > "ecrou-v$v-darwin-arm64.tar.gz.sha256"
|
||||||
|
mkdir dist && mv ecrou-v$v-darwin-* dist/
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ecrou-darwin-arm64
|
||||||
|
path: dist/*
|
||||||
|
|
||||||
|
# The tap repo (vhaudiquet/homebrew-ecrou) is a publish target, not
|
||||||
|
# the source of truth: the formula is maintained in packaging/ and
|
||||||
|
# this job rewrites its url/sha256 for the release tarball (the
|
||||||
|
# binary publish-release just attached) and pushes it. Needs a PAT with push access to the tap repo as the
|
||||||
|
# HOMEBREW_TAP_TOKEN secret; without it the job is a no-op so tags
|
||||||
|
# still release.
|
||||||
|
# The Homebrew formula pours this tarball, so it has to be attached
|
||||||
|
# to the release before the tap update runs.
|
||||||
|
publish-release:
|
||||||
|
needs: build-macos
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ecrou-darwin-arm64
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Create the release and upload the tarball
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
v="${GITHUB_REF_NAME#v}"
|
||||||
|
gh release create "v$v" --title "ecrou $v" --generate-notes || true
|
||||||
|
gh release upload "v$v" dist/* --clobber
|
||||||
|
|
||||||
|
update-homebrew:
|
||||||
|
needs: publish-release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ecrou-darwin-arm64
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Push the formula to the tap
|
||||||
|
env:
|
||||||
|
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -z "$TAP_TOKEN" ]]; then
|
||||||
|
echo "::notice::no HOMEBREW_TAP_TOKEN secret set; tap not updated"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
v="${GITHUB_REF_NAME#v}"
|
||||||
|
sha="$(cut -d' ' -f1 "dist/ecrou-v$v-darwin-arm64.tar.gz.sha256")"
|
||||||
|
url="https://github.com/vhaudiquet/ecrou/releases/download/v$v/ecrou-v$v-darwin-arm64.tar.gz"
|
||||||
|
mkdir -p formula/Formula
|
||||||
|
sed -e '/^ # Placeholder pin:/d' -e "s|^ url \"https://github.com/vhaudiquet/ecrou.git\", tag: \"v[0-9][0-9.]*\"$| url \"$url\"\n sha256 \"$sha\"|" packaging/homebrew/Formula/ecrou.rb > formula/Formula/ecrou.rb
|
||||||
|
grep -q "sha256 \"$sha\"" formula/Formula/ecrou.rb
|
||||||
|
# A missing tap repo skips with a notice; a token that
|
||||||
|
# cannot clone an existing repo still fails the job.
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer $TAP_TOKEN" \
|
||||||
|
https://api.github.com/repos/vhaudiquet/homebrew-ecrou)"
|
||||||
|
if [[ "$code" != "200" ]]; then
|
||||||
|
echo "::notice::vhaudiquet/homebrew-ecrou unreachable (HTTP $code); tap not updated"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git clone -q "https://x-access-token:$TAP_TOKEN@github.com/vhaudiquet/homebrew-ecrou" tap
|
||||||
|
mkdir -p tap/Formula
|
||||||
|
cp formula/Formula/ecrou.rb tap/Formula/ecrou.rb
|
||||||
|
cd tap
|
||||||
|
git add Formula/ecrou.rb
|
||||||
|
git -c user.name="ecrou-release" -c user.email="noreply@github.com" \
|
||||||
|
commit -m "ecrou $v"
|
||||||
|
git push -q origin HEAD:main
|
||||||
|
|
||||||
publish-snap:
|
publish-snap:
|
||||||
needs: build-snap
|
needs: build-snap
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
Generated
+1
@@ -385,6 +385,7 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"ecrou",
|
"ecrou",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,18 @@
|
|||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
ecrou is available as a strictly confined snap:
|
Linux (strictly confined snap, KVM required):
|
||||||
```
|
```
|
||||||
sudo snap install ecrou
|
sudo snap install ecrou
|
||||||
sudo snap connect ecrou:kvm
|
sudo snap connect ecrou:kvm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
macOS (Apple Silicon, Homebrew):
|
||||||
|
```
|
||||||
|
brew trust --tap libkrun/krun
|
||||||
|
brew install vhaudiquet/ecrou/ecrou
|
||||||
|
```
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ and fails with a remediation message naming the install when it is
|
|||||||
missing; nothing links at build time, and the snap bundles the library
|
missing; nothing links at build time, and the snap bundles the library
|
||||||
plus libkrunfw next to itself.
|
plus libkrunfw next to itself.
|
||||||
|
|
||||||
KVM is required; there is no TCG fallback. A machine without working
|
KVM is required on Linux, Hypervisor.framework on macOS; there is no
|
||||||
KVM is `Error::KvmUnavailable` with the remediation text, and `ecrou
|
TCG fallback. A machine without working KVM is `Error::KvmUnavailable`
|
||||||
probe` reports it before any run. The VMM runs inside the machine
|
with the remediation text, and `ecrou probe` reports it before any
|
||||||
|
run (on macOS the probe goes further: it checks the hypervisor
|
||||||
|
entitlement too, see the macOS section). The VMM runs inside the machine
|
||||||
child, not on the caller's thread (see Cancellation).
|
child, not on the caller's thread (see Cancellation).
|
||||||
|
|
||||||
libkrun's mapped-volumes entry point is explicitly unsupported on
|
libkrun's mapped-volumes entry point is explicitly unsupported on
|
||||||
@@ -282,6 +284,82 @@ stop:
|
|||||||
point: 24 s end to end, boot and apt included, cache-warm (snap
|
point: 24 s end to end, boot and apt included, cache-warm (snap
|
||||||
0.1.0 engine, October 2026).
|
0.1.0 engine, October 2026).
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
The same ABI, a different substrate. The engine is dlopen'd from the
|
||||||
|
Homebrew kegs (`libkrun` from the `libkrun/homebrew-krun` tap, which
|
||||||
|
builds the HVF backend and links Hypervisor.framework); the keg path
|
||||||
|
is a search fallback, not a hard-coded prefix, and the run fails with
|
||||||
|
a remediation naming the tap when the library is absent. macOS needs
|
||||||
|
no snap: no store sandbox, no staged libraries — Homebrew's prefix is
|
||||||
|
the distribution channel. There is no riscv64 or amd64 build; libkrun
|
||||||
|
supports Hypervisor.framework on arm64 only.
|
||||||
|
|
||||||
|
Two macOS facts shape the code:
|
||||||
|
|
||||||
|
**Bare-soname dlopens.** The engine loads `libkrunfw.5.dylib` by bare
|
||||||
|
soname at `krun_start_enter`, and a libarchive-capable `mke2fs` loads
|
||||||
|
`libarchive.13.dylib` the same way. dyld never searches the brew
|
||||||
|
prefix, and it reads `DYLD_FALLBACK_LIBRARY_PATH` once, at process
|
||||||
|
startup — a setenv afterwards is invisible to it. The CLI therefore
|
||||||
|
re-execs itself (exec restarts dyld) when the variable is unset and
|
||||||
|
the kegs exist; library consumers must set the variable themselves
|
||||||
|
before their process starts. mkfs children are exec'd fresh, so the
|
||||||
|
per-spawn env in image.rs works regardless.
|
||||||
|
|
||||||
|
**The hypervisor entitlement.** Hypervisor.framework grants VM
|
||||||
|
creation only to binaries signed with the
|
||||||
|
`com.apple.security.hypervisor` entitlement — current macOS enforces
|
||||||
|
this for ad-hoc-signed binaries too, where older releases granted the
|
||||||
|
framework implicitly. The denial surfaces as `hv_vm_create` returning
|
||||||
|
`HV_DENIED` (0xfae94007), which without a probe showed up only as an
|
||||||
|
opaque `krun_start_enter` EINVAL after a full image build. `probe` now
|
||||||
|
creates and destroys a real framework VM (dlopen'd, no link-time
|
||||||
|
dependency) so the denial is reported with the re-sign remediation
|
||||||
|
before any image work. Packaging signs at install (the Homebrew
|
||||||
|
formula, the same pattern krunkit's formula carries, and the release
|
||||||
|
tarball's codesign step); a `cargo build` developer re-signs after
|
||||||
|
each build, per the README snippet.
|
||||||
|
|
||||||
|
The ext4 image is built the same way as on Linux, with one fallback:
|
||||||
|
when the found `mke2fs` cannot populate from a tarball (Homebrew's
|
||||||
|
e2fsprogs bottle builds without libarchive), the image builder
|
||||||
|
unpacks the rootfs to a tree and lets mke2fs populate from the
|
||||||
|
directory — slower, and run states it in the log. The T6 strace audit
|
||||||
|
and the `/dev/kvm` gating stay Linux-only: strace does not exist on
|
||||||
|
macOS and the hypervisor gate is the entitlement probe instead. CI
|
||||||
|
runs the whole gate on a `macos-26` runner; hosted macOS runners
|
||||||
|
expose no Hypervisor.framework, so the boot e2e is the Linux snap
|
||||||
|
e2e and any real Mac — CI only asserts that the probe loads the
|
||||||
|
engine from the krun tap and reports the HVF absence. The release
|
||||||
|
workflow ships a signed darwin-arm64 tarball (build and sign only,
|
||||||
|
no boot).
|
||||||
|
|
||||||
|
## Homebrew
|
||||||
|
|
||||||
|
`packaging/homebrew/Formula/ecrou.rb` installs into a
|
||||||
|
`vhaudiquet/homebrew-ecrou` tap — the repo name is what makes
|
||||||
|
`brew install vhaudiquet/ecrou/ecrou` resolve without an explicit
|
||||||
|
`brew tap`. The formula pours the darwin tarball the release
|
||||||
|
workflow signs and attaches to the GitHub release: no local compile,
|
||||||
|
and the entitlement signature survives, which a rebuild here would
|
||||||
|
strip. The tap repo is a publish target, not the source of
|
||||||
|
truth: the release workflow rewrites the formula's url/sha256 for
|
||||||
|
the release tarball and pushes it (gated on a `HOMEBREW_TAP_TOKEN`
|
||||||
|
PAT; without the secret the job no-ops). The formula carries the runtime
|
||||||
|
dependencies (`libkrun` from the krun tap, `e2fsprogs`, `libarchive`),
|
||||||
|
builds from source with cargo, and re-signs the binary with the
|
||||||
|
entitlements plist from `packaging/homebrew/` — brew bottles are
|
||||||
|
ad-hoc signed, so the signature is part of the formula's install,
|
||||||
|
not of the artifact. The in-repo formula's tag pin is a placeholder:
|
||||||
|
the release workflow rewrites url and sha256 (of the release asset,
|
||||||
|
not the source archive — they are different files) when it pushes
|
||||||
|
the formula to the tap. Homebrew's tap-trust gate refuses dependencies
|
||||||
|
from third-party taps until their tap is trusted, so the README
|
||||||
|
leads with `brew trust --tap libkrun/krun` and CI trusts the tap
|
||||||
|
before its install; removing that tax is another argument for
|
||||||
|
libkrun landing in homebrew-core.
|
||||||
|
|
||||||
## Snap packaging
|
## Snap packaging
|
||||||
|
|
||||||
Confinement: strict. Plugs: `network`, `home`, `kvm`. The manual
|
Confinement: strict. Plugs: `network`, `home`, `kvm`. The manual
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ name = "ecrou"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
libc = "0.2"
|
||||||
ecrou = { path = "../ecrou" }
|
ecrou = { path = "../ecrou" }
|
||||||
|
|
||||||
# CLI parsing
|
# CLI parsing
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use cli::{parse_env, parse_volume, Cli, Command, RunArgs};
|
|||||||
use progress::TermProgress;
|
use progress::TermProgress;
|
||||||
|
|
||||||
fn main() -> std::process::ExitCode {
|
fn main() -> std::process::ExitCode {
|
||||||
|
reexec_with_dyld_fallback();
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
logger::init(cli.verbose);
|
logger::init(cli.verbose);
|
||||||
match cli.command {
|
match cli.command {
|
||||||
@@ -25,6 +26,67 @@ fn main() -> std::process::ExitCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The engine's dlopen of libkrunfw and mke2fs's dlopen of libarchive
|
||||||
|
/// both name bare sonames, and dyld reads DYLD_FALLBACK_LIBRARY_PATH
|
||||||
|
/// only once, at process startup — a setenv afterwards is invisible to
|
||||||
|
/// it. When the variable is unset and the brew kegs exist, re-exec:
|
||||||
|
/// exec restarts dyld, which reads the variable fresh. Library
|
||||||
|
/// consumers instead set the variable themselves before their process
|
||||||
|
/// starts (SPEC.md, macOS).
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn reexec_with_dyld_fallback() {
|
||||||
|
use std::os::unix::ffi::{OsStrExt as _, OsStringExt};
|
||||||
|
|
||||||
|
if std::env::var_os("DYLD_FALLBACK_LIBRARY_PATH").is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dirs = match ecrou::utils::dyld_fallback_dirs() {
|
||||||
|
Some(dirs) => dirs,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
std::env::set_var("DYLD_FALLBACK_LIBRARY_PATH", &dirs);
|
||||||
|
let program = match std::env::current_exe() {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut argv: Vec<std::ffi::CString> = Vec::new();
|
||||||
|
let name = program
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
argv.push(match std::ffi::CString::new(name.into_vec()) {
|
||||||
|
Ok(name) => name,
|
||||||
|
Err(_) => return,
|
||||||
|
});
|
||||||
|
let program = match std::ffi::CString::new(program.as_os_str().as_bytes()) {
|
||||||
|
Ok(program) => program,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
for arg in std::env::args_os().skip(1) {
|
||||||
|
match std::ffi::CString::new(arg.into_vec()) {
|
||||||
|
Ok(arg) => argv.push(arg),
|
||||||
|
// An argument with interior NULs cannot cross execv at
|
||||||
|
// all; the original process would have failed the same
|
||||||
|
// way, so bail out of the re-exec quietly.
|
||||||
|
Err(_) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Safety: execv replaces this process with itself, same program;
|
||||||
|
// on failure the process keeps running exactly as before.
|
||||||
|
// execv takes a C array of pointers into the strings, NUL-ended;
|
||||||
|
// the CStrings stay alive in `strings` across the call.
|
||||||
|
let mut pointers: Vec<*const libc::c_char> = argv.iter().map(|s| s.as_ptr().cast()).collect();
|
||||||
|
pointers.push(std::ptr::null());
|
||||||
|
// Safety: execv replaces this process with itself, same program;
|
||||||
|
// on failure the process keeps running exactly as before.
|
||||||
|
unsafe {
|
||||||
|
libc::execv(program.as_ptr().cast(), pointers.as_ptr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn reexec_with_dyld_fallback() {}
|
||||||
|
|
||||||
fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
|
fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
|
||||||
// A tty drives the payload's output through the terminal while the
|
// A tty drives the payload's output through the terminal while the
|
||||||
// capture share points the same streams at files: one of them has
|
// capture share points the same streams at files: one of them has
|
||||||
@@ -116,8 +178,15 @@ fn cmd_probe() -> std::process::ExitCode {
|
|||||||
|
|
||||||
let mut ok = true;
|
let mut ok = true;
|
||||||
|
|
||||||
print!("kvm: ");
|
print!(
|
||||||
match krun::kvm_probe() {
|
"{}: ",
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
"hypervisor"
|
||||||
|
} else {
|
||||||
|
"kvm"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
match krun::hypervisor_probe() {
|
||||||
Ok(()) => println!("ok"),
|
Ok(()) => println!("ok"),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
ok = false;
|
ok = false;
|
||||||
@@ -145,12 +214,20 @@ fn cmd_probe() -> std::process::ExitCode {
|
|||||||
print!("e2fsprogs: ");
|
print!("e2fsprogs: ");
|
||||||
let mut tools_ok = true;
|
let mut tools_ok = true;
|
||||||
for tool in ["mkfs.ext4", "debugfs"] {
|
for tool in ["mkfs.ext4", "debugfs"] {
|
||||||
match std::process::Command::new(tool).arg("-V").output() {
|
match ecrou::utils::find_tool(tool) {
|
||||||
|
Some(path) => match std::process::Command::new(path).arg("-V").output() {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
ok = false;
|
ok = false;
|
||||||
tools_ok = false;
|
tools_ok = false;
|
||||||
println!("missing {tool} ({err}); the rootfs image cannot be built");
|
println!("{tool} not runnable ({err}); the rootfs image cannot be built");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
ok = false;
|
||||||
|
tools_ok = false;
|
||||||
|
println!("missing {tool}; install e2fsprogs, the rootfs image cannot be built");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const KVM_NESTED_HINT: &str = "only actionable on x86_64 hosts";
|
|||||||
|
|
||||||
/// Errors returned by the ecrou library API.
|
/// Errors returned by the ecrou library API.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[allow(clippy::enum_variant_names)] // KvmUnavailable is the platform's word; HvUnavailable is macOS's
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
/// No working KVM on this host, and ecrou has no software-emulation
|
/// No working KVM on this host, and ecrou has no software-emulation
|
||||||
/// fallback. The remediation names the usual causes in order; the
|
/// fallback. The remediation names the usual causes in order; the
|
||||||
@@ -49,6 +50,18 @@ pub enum Error {
|
|||||||
)]
|
)]
|
||||||
KvmUnavailable(#[source] std::io::Error),
|
KvmUnavailable(#[source] std::io::Error),
|
||||||
|
|
||||||
|
/// No hardware virtualization on this Mac, and ecrou has no
|
||||||
|
/// software-emulation fallback: libkrun's macOS backend runs on
|
||||||
|
/// the Hypervisor.framework, which means Apple Silicon and
|
||||||
|
/// macOS 14 or newer.
|
||||||
|
#[error(
|
||||||
|
"Hypervisor.framework is not available\n\n\
|
||||||
|
ecrou boots microVMs and needs hardware virtualization:\n\
|
||||||
|
\x20 host: an Apple Silicon Mac (M1 or later)\n\
|
||||||
|
\x20 os: macOS 14 or newer"
|
||||||
|
)]
|
||||||
|
HvUnavailable(#[source] std::io::Error),
|
||||||
|
|
||||||
/// The registry publishes no linux manifest for the requested
|
/// The registry publishes no linux manifest for the requested
|
||||||
/// architecture; `available` lists the architectures it does serve.
|
/// architecture; `available` lists the architectures it does serve.
|
||||||
#[error("No manifest found for architecture '{arch}'. Available: {available}")]
|
#[error("No manifest found for architecture '{arch}'. Available: {available}")]
|
||||||
@@ -92,6 +105,19 @@ mod tests {
|
|||||||
assert!(printed.contains("Permission denied"), "{printed}");
|
assert!(printed.contains("Permission denied"), "{printed}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn hv_unavailable_renders_remediation() {
|
||||||
|
let msg = Error::HvUnavailable(std::io::Error::other("no hvf")).to_string();
|
||||||
|
assert!(msg.contains("Hypervisor.framework is not available"));
|
||||||
|
assert!(msg.contains("Apple Silicon"));
|
||||||
|
let printed = format!(
|
||||||
|
"{:#}",
|
||||||
|
anyhow::Error::new(Error::HvUnavailable(std::io::Error::other("no hvf")))
|
||||||
|
);
|
||||||
|
assert!(printed.contains("no hvf"), "{printed}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_typed_error_survives_the_anyhow_conversion() {
|
fn a_typed_error_survives_the_anyhow_conversion() {
|
||||||
let raised =
|
let raised =
|
||||||
|
|||||||
+162
-22
@@ -9,6 +9,14 @@
|
|||||||
//! lose them. An unprivileged extract cannot preserve a tar's owners
|
//! lose them. An unprivileged extract cannot preserve a tar's owners
|
||||||
//! (chown to an arbitrary uid is root's alone); a tarball to image
|
//! (chown to an arbitrary uid is root's alone); a tarball to image
|
||||||
//! pipeline never needs one.
|
//! pipeline never needs one.
|
||||||
|
//!
|
||||||
|
//! Tarball populate is a build-time choice in e2fsprogs: compiled in,
|
||||||
|
//! it loads libarchive by dlopen at runtime (`libarchive.so.13` on
|
||||||
|
//! Linux, `libarchive.13.dylib` on macOS), so presence cannot be
|
||||||
|
//! assumed from the binary. A build without it (Homebrew's bottle, for
|
||||||
|
//! one) gets the tree-populate fallback: the tarball is unpacked and
|
||||||
|
//! mke2fs copies the directory, which loses the tar owners — flagged
|
||||||
|
//! through the progress channel, never silently.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -17,6 +25,16 @@ use anyhow::{anyhow, Context};
|
|||||||
|
|
||||||
use crate::progress::Progress;
|
use crate::progress::Progress;
|
||||||
|
|
||||||
|
/// Appended to a mkfs failure whose stderr names libarchive: the
|
||||||
|
/// tarball populate loads it by dlopen at runtime, and the message
|
||||||
|
/// names the platform's way to get the dylib in place.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const LIBARCHIVE_REMEDIATION: &str = "; the populate step loads libarchive \
|
||||||
|
by dlopen at runtime, and `brew install libarchive` provides the dylib";
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
const LIBARCHIVE_REMEDIATION: &str = "; the populate step loads libarchive \
|
||||||
|
at runtime (the snap stages libarchive13 next to the binary)";
|
||||||
|
|
||||||
/// Build the rootfs image at `image` from `tarball`.
|
/// Build the rootfs image at `image` from `tarball`.
|
||||||
///
|
///
|
||||||
/// An OCI layer bundle merges into one plain tar first, so the whiteout
|
/// An OCI layer bundle merges into one plain tar first, so the whiteout
|
||||||
@@ -41,7 +59,7 @@ pub(crate) fn build_rootfs_image(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let source: &Path = merged.as_deref().unwrap_or(tarball);
|
let source: &Path = merged.as_deref().unwrap_or(tarball);
|
||||||
let result = mkfs_from_tar(source, image);
|
let result = mkfs_from_tar(source, image, progress);
|
||||||
if let Some(side) = merged {
|
if let Some(side) = merged {
|
||||||
// The merged archive has value only until mke2fs consumes it;
|
// The merged archive has value only until mke2fs consumes it;
|
||||||
// a failure to clean up must not mask mkfs's own error.
|
// a failure to clean up must not mask mkfs's own error.
|
||||||
@@ -71,7 +89,7 @@ pub(crate) fn looks_like_ext4(image: &Path) -> bool {
|
|||||||
/// Build the entry image: mke2fs consumes the archive (merged first
|
/// Build the entry image: mke2fs consumes the archive (merged first
|
||||||
/// when the tarball is an OCI layer bundle) and the result publishes
|
/// when the tarball is an OCI layer bundle) and the result publishes
|
||||||
/// atomically.
|
/// atomically.
|
||||||
fn mkfs_from_tar(tarball: &Path, image: &Path) -> anyhow::Result<()> {
|
fn mkfs_from_tar(tarball: &Path, image: &Path, progress: &dyn Progress) -> anyhow::Result<()> {
|
||||||
// The build lands in a sibling file first and renames over the
|
// The build lands in a sibling file first and renames over the
|
||||||
// image only on success. mke2fs creates and sizes the file before
|
// image only on success. mke2fs creates and sizes the file before
|
||||||
// it populates, so a failed build would otherwise leave a
|
// it populates, so a failed build would otherwise leave a
|
||||||
@@ -79,7 +97,7 @@ fn mkfs_from_tar(tarball: &Path, image: &Path) -> anyhow::Result<()> {
|
|||||||
// than the tarball it came from, and trusted by the freshness
|
// than the tarball it came from, and trusted by the freshness
|
||||||
// check forever after.
|
// check forever after.
|
||||||
let staging = image.with_extension("part");
|
let staging = image.with_extension("part");
|
||||||
let built = build_ext4(tarball, &staging);
|
let built = build_ext4(tarball, &staging, progress);
|
||||||
if built.is_ok() {
|
if built.is_ok() {
|
||||||
std::fs::rename(&staging, image)
|
std::fs::rename(&staging, image)
|
||||||
.with_context(|| format!("publishing {}", image.display()))?;
|
.with_context(|| format!("publishing {}", image.display()))?;
|
||||||
@@ -89,10 +107,75 @@ fn mkfs_from_tar(tarball: &Path, image: &Path) -> anyhow::Result<()> {
|
|||||||
built
|
built
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the system mke2fs populates straight from a tarball. The
|
||||||
|
/// support is compiled in or out (and dlopen'd at runtime when in), so
|
||||||
|
/// the only honest probe is the real tool over a minimal archive:
|
||||||
|
/// build a four-MiB image from a one-entry tarball once per process
|
||||||
|
/// and cache the verdict.
|
||||||
|
fn mke2fs_reads_tarballs() -> bool {
|
||||||
|
static VERDICT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
*VERDICT.get_or_init(|| {
|
||||||
|
let scratch = match tempfile::tempdir() {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let tarball = scratch.path().join("probe.tar");
|
||||||
|
let file = match std::fs::File::create(&tarball) {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let mut builder = tar::Builder::new(file);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(0);
|
||||||
|
header.set_mode(0o755);
|
||||||
|
header.set_entry_type(tar::EntryType::Regular);
|
||||||
|
if builder
|
||||||
|
.append_data(&mut header, "probe", std::io::empty())
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let image = scratch.path().join("probe.ext4");
|
||||||
|
if std::fs::File::create(&image)
|
||||||
|
.and_then(|f| f.set_len(4 * 1024 * 1024))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match mkfs_tool() {
|
||||||
|
Some(mut cmd) => cmd
|
||||||
|
.args(["-q", "-F", "-N", "16", "-d"])
|
||||||
|
.arg(&tarball)
|
||||||
|
.arg(&image)
|
||||||
|
.output()
|
||||||
|
.map(|out| out.status.success())
|
||||||
|
.unwrap_or(false),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mke2fs command, resolved through the tool lookup and, on macOS,
|
||||||
|
/// pointed at the directories a dlopen'd libarchive can live in.
|
||||||
|
fn mkfs_tool() -> Option<std::process::Command> {
|
||||||
|
let mut cmd = Command::new(crate::utils::find_tool("mkfs.ext4")?);
|
||||||
|
// The child execs fresh, so it reads the variable at startup even
|
||||||
|
// when the parent process itself never had it.
|
||||||
|
if let Some(dirs) = crate::utils::dyld_fallback_dirs() {
|
||||||
|
cmd.env("DYLD_FALLBACK_LIBRARY_PATH", dirs);
|
||||||
|
}
|
||||||
|
Some(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The debugfs command, resolved through the tool lookup.
|
||||||
|
fn debugfs_tool() -> Option<Command> {
|
||||||
|
crate::utils::find_tool("debugfs").map(Command::new)
|
||||||
|
}
|
||||||
|
|
||||||
/// Create `image`, size it from a scan of the archive, and run mke2fs
|
/// Create `image`, size it from a scan of the archive, and run mke2fs
|
||||||
/// over it. The filesystem size and inode count come from the scan:
|
/// over it. The filesystem size and inode count come from the scan:
|
||||||
/// mke2fs derives neither from a tar.
|
/// mke2fs derives neither from a tar.
|
||||||
fn build_ext4(tarball: &Path, image: &Path) -> anyhow::Result<()> {
|
fn build_ext4(tarball: &Path, image: &Path, progress: &dyn Progress) -> anyhow::Result<()> {
|
||||||
let (bytes, entries) = tar_scan(tarball)?;
|
let (bytes, entries) = tar_scan(tarball)?;
|
||||||
// Headroom for the journal, the inode tables and block rounding.
|
// Headroom for the journal, the inode tables and block rounding.
|
||||||
// The file is created sparse, so generous headroom costs nothing
|
// The file is created sparse, so generous headroom costs nothing
|
||||||
@@ -115,16 +198,44 @@ fn build_ext4(tarball: &Path, image: &Path) -> anyhow::Result<()> {
|
|||||||
.with_context(|| format!("sizing {}", image.display()))?;
|
.with_context(|| format!("sizing {}", image.display()))?;
|
||||||
drop(file);
|
drop(file);
|
||||||
|
|
||||||
let out = Command::new("mkfs.ext4")
|
// Tarball populate when the tool can, tree populate when it
|
||||||
|
// cannot: the fallback loses the tar's owners (every entry takes
|
||||||
|
// the invoking user's), which is said out loud, not swallowed.
|
||||||
|
let extracted: Option<PathBuf> = if mke2fs_reads_tarballs() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let dir = image.with_extension("populate");
|
||||||
|
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||||
|
crate::extract::tar_decoder(tarball)?
|
||||||
|
.unpack(&dir)
|
||||||
|
.with_context(|| format!("unpacking {} into {}", tarball.display(), dir.display()))?;
|
||||||
|
progress.message(
|
||||||
|
"this mke2fs cannot read tarballs (no libarchive); populating \
|
||||||
|
from the extracted tree, so tar owners are lost",
|
||||||
|
);
|
||||||
|
Some(dir)
|
||||||
|
};
|
||||||
|
let populate: &Path = extracted.as_deref().unwrap_or(tarball);
|
||||||
|
let out = mkfs_tool()
|
||||||
|
.ok_or_else(|| anyhow!("mkfs.ext4 not found: install e2fsprogs"))?
|
||||||
.args(["-q", "-F", "-N", &inodes.to_string(), "-d"])
|
.args(["-q", "-F", "-N", &inodes.to_string(), "-d"])
|
||||||
.arg(tarball)
|
.arg(populate)
|
||||||
.arg(image)
|
.arg(image)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| anyhow!("mkfs.ext4 not found ({e}): install e2fsprogs; the snap stages it"))?;
|
.map_err(|e| anyhow!("mkfs.ext4 not runnable ({e}): install e2fsprogs"))?;
|
||||||
|
if let Some(dir) = extracted {
|
||||||
|
// A failure to clean the tree must not mask mkfs's own verdict.
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
let note = if stderr.contains("libarchive") {
|
||||||
|
LIBARCHIVE_REMEDIATION
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"mkfs.ext4 failed building the rootfs image: {}",
|
"mkfs.ext4 failed building the rootfs image: {stderr}{note}"
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -143,10 +254,20 @@ fn tar_scan(tarball: &Path) -> anyhow::Result<(u64, u64)> {
|
|||||||
Ok((bytes, entries))
|
Ok((bytes, entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy the cache entry image into the scratch directory. cp reflinks
|
/// Copy the cache entry image into the scratch directory. On Linux cp
|
||||||
/// where the filesystem has them and keeps the copy sparse either way:
|
/// reflinks where the filesystem has them and keeps the copy sparse;
|
||||||
/// an image occupying 400 MiB must not materialize a GiB of holes.
|
/// an image occupying 400 MiB must not materialize a GiB of holes. On
|
||||||
|
/// macOS APFS clones the file at the block level through fclonefileat,
|
||||||
|
/// which std::fs::copy drives (and a cross-volume copy falls back to a
|
||||||
|
/// plain read/write).
|
||||||
pub(crate) fn copy_image(src: &Path, dst: &Path) -> anyhow::Result<()> {
|
pub(crate) fn copy_image(src: &Path, dst: &Path) -> anyhow::Result<()> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
std::fs::copy(src, dst)
|
||||||
|
.with_context(|| format!("copying {} to {}", src.display(), dst.display()))?;
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
let out = Command::new("cp")
|
let out = Command::new("cp")
|
||||||
.args(["--reflink=auto", "--sparse=auto"])
|
.args(["--reflink=auto", "--sparse=auto"])
|
||||||
.arg(src)
|
.arg(src)
|
||||||
@@ -159,6 +280,7 @@ pub(crate) fn copy_image(src: &Path, dst: &Path) -> anyhow::Result<()> {
|
|||||||
String::from_utf8_lossy(&out.stderr).trim()
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,12 +312,13 @@ pub(crate) fn provision(
|
|||||||
script.push_str(&format!("sif {guest} mode 0{:o}\n", 0o100000 | mode));
|
script.push_str(&format!("sif {guest} mode 0{:o}\n", 0o100000 | mode));
|
||||||
}
|
}
|
||||||
std::fs::write(&cmds, script).with_context(|| format!("writing {}", cmds.display()))?;
|
std::fs::write(&cmds, script).with_context(|| format!("writing {}", cmds.display()))?;
|
||||||
let out = Command::new("debugfs")
|
let out = debugfs_tool()
|
||||||
|
.ok_or_else(|| anyhow!("debugfs not found: install e2fsprogs"))?
|
||||||
.args(["-w", "-f"])
|
.args(["-w", "-f"])
|
||||||
.arg(&cmds)
|
.arg(&cmds)
|
||||||
.arg(image)
|
.arg(image)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs; the snap stages it"))?;
|
.map_err(|e| anyhow!("debugfs not runnable ({e})"))?;
|
||||||
let _ = std::fs::remove_file(&cmds);
|
let _ = std::fs::remove_file(&cmds);
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
@@ -212,12 +335,13 @@ pub(crate) fn provision(
|
|||||||
/// reader (the exit-code control file) holds an integer, so those
|
/// reader (the exit-code control file) holds an integer, so those
|
||||||
/// lines are dropped. An absent file reads back empty.
|
/// lines are dropped. An absent file reads back empty.
|
||||||
pub(crate) fn read_file(image: &Path, guest: &str) -> anyhow::Result<String> {
|
pub(crate) fn read_file(image: &Path, guest: &str) -> anyhow::Result<String> {
|
||||||
let out = Command::new("debugfs")
|
let out = debugfs_tool()
|
||||||
|
.ok_or_else(|| anyhow!("debugfs not found: install e2fsprogs"))?
|
||||||
.arg("-R")
|
.arg("-R")
|
||||||
.arg(format!("cat {guest}"))
|
.arg(format!("cat {guest}"))
|
||||||
.arg(image)
|
.arg(image)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs"))?;
|
.map_err(|e| anyhow!("debugfs not runnable ({e})"))?;
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
Ok(text
|
Ok(text
|
||||||
.lines()
|
.lines()
|
||||||
@@ -229,12 +353,13 @@ pub(crate) fn read_file(image: &Path, guest: &str) -> anyhow::Result<String> {
|
|||||||
/// Whether a guest path exists in the image: a debugfs stat that finds
|
/// Whether a guest path exists in the image: a debugfs stat that finds
|
||||||
/// an inode. Symlinks resolve; stat follows them like the guest will.
|
/// an inode. Symlinks resolve; stat follows them like the guest will.
|
||||||
pub(crate) fn stat_exists(image: &Path, guest: &str) -> anyhow::Result<bool> {
|
pub(crate) fn stat_exists(image: &Path, guest: &str) -> anyhow::Result<bool> {
|
||||||
let out = Command::new("debugfs")
|
let out = debugfs_tool()
|
||||||
|
.ok_or_else(|| anyhow!("debugfs not found: install e2fsprogs"))?
|
||||||
.arg("-R")
|
.arg("-R")
|
||||||
.arg(format!("stat {guest}"))
|
.arg(format!("stat {guest}"))
|
||||||
.arg(image)
|
.arg(image)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs"))?;
|
.map_err(|e| anyhow!("debugfs not runnable ({e})"))?;
|
||||||
Ok(String::from_utf8_lossy(&out.stdout).contains("Inode:"))
|
Ok(String::from_utf8_lossy(&out.stdout).contains("Inode:"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,8 +381,8 @@ mod tests {
|
|||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
|
||||||
fn have_e2fsprogs() -> bool {
|
fn have_e2fsprogs() -> bool {
|
||||||
Command::new("mkfs.ext4").arg("-V").output().is_ok()
|
crate::utils::find_tool("mkfs.ext4").is_some()
|
||||||
&& Command::new("debugfs").arg("-V").output().is_ok()
|
&& crate::utils::find_tool("debugfs").is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tarball with one root-owned directory and a file owned by uid
|
/// A tarball with one root-owned directory and a file owned by uid
|
||||||
@@ -343,13 +468,16 @@ mod tests {
|
|||||||
assert!(looks_like_ext4(&image), "the built image lacks the magic");
|
assert!(looks_like_ext4(&image), "the built image lacks the magic");
|
||||||
assert!(!work.path().join("rootfs.part").exists());
|
assert!(!work.path().join("rootfs.part").exists());
|
||||||
|
|
||||||
let out = Command::new("debugfs")
|
let out = debugfs_tool()
|
||||||
|
.unwrap()
|
||||||
.arg("-R")
|
.arg("-R")
|
||||||
.arg("stat /etc/marker")
|
.arg("stat /etc/marker")
|
||||||
.arg(&image)
|
.arg(&image)
|
||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let stat = String::from_utf8_lossy(&out.stdout);
|
let stat = String::from_utf8_lossy(&out.stdout);
|
||||||
|
if mke2fs_reads_tarballs() {
|
||||||
|
// Tarball populate bakes the archive's owners in.
|
||||||
assert!(
|
assert!(
|
||||||
stat.lines()
|
stat.lines()
|
||||||
.any(|l| l.contains("User:") && l.contains("104")),
|
.any(|l| l.contains("User:") && l.contains("104")),
|
||||||
@@ -360,6 +488,17 @@ mod tests {
|
|||||||
.any(|l| l.contains("Group:") && l.contains("104")),
|
.any(|l| l.contains("Group:") && l.contains("104")),
|
||||||
"{stat}"
|
"{stat}"
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// The tree fallback cannot: every entry carries the
|
||||||
|
// invoking user's ids, which is the documented loss.
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let uid = std::fs::metadata(&image).unwrap().uid();
|
||||||
|
assert!(
|
||||||
|
stat.lines()
|
||||||
|
.any(|l| l.contains("User:") && l.contains(&uid.to_string())),
|
||||||
|
"{stat}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -393,7 +532,8 @@ mod tests {
|
|||||||
assert!(stat_exists(&image, "/ecrou-init").unwrap());
|
assert!(stat_exists(&image, "/ecrou-init").unwrap());
|
||||||
assert!(!stat_exists(&image, "/etc/marker-does-not-exist").unwrap());
|
assert!(!stat_exists(&image, "/etc/marker-does-not-exist").unwrap());
|
||||||
|
|
||||||
let out = Command::new("debugfs")
|
let out = debugfs_tool()
|
||||||
|
.unwrap()
|
||||||
.arg("-R")
|
.arg("-R")
|
||||||
.arg("stat /ecrou-init")
|
.arg("stat /ecrou-init")
|
||||||
.arg(&image)
|
.arg(&image)
|
||||||
|
|||||||
+183
-19
@@ -1,13 +1,14 @@
|
|||||||
//! Runtime bindings to libkrun's stable C ABI.
|
//! Runtime bindings to libkrun's stable C ABI.
|
||||||
//!
|
//!
|
||||||
//! The library (`libkrun.so.1`) loads through dlopen so a missing
|
//! The library (`libkrun.so.1` on Linux, `libkrun.1.dylib` on macOS)
|
||||||
//! install is a remediation message rather than a link error. The
|
//! loads through dlopen so a missing install is a remediation message
|
||||||
|
//! rather than a link error. The
|
||||||
//! symbol signatures mirror the `stable-1.19.x` headers; the main
|
//! symbol signatures mirror the `stable-1.19.x` headers; the main
|
||||||
//! branch is mid-rewrite to a vtable API and is not the target.
|
//! branch is mid-rewrite to a vtable API and is not the target.
|
||||||
//!
|
//!
|
||||||
//! The founding rule holds for this module too: libkrun's VMM runs in
|
//! The founding rule holds for this module too: libkrun's VMM runs in
|
||||||
//! our process and touches only files, sockets, and `/dev/kvm`, all
|
//! our process and touches only files and sockets (plus `/dev/kvm` on
|
||||||
//! inside the confinement budget (SPEC.md).
|
//! Linux), all inside the confinement budget (SPEC.md).
|
||||||
|
|
||||||
use std::ffi::{c_char, CString};
|
use std::ffi::{c_char, CString};
|
||||||
use std::os::unix::ffi::OsStrExt;
|
use std::os::unix::ffi::OsStrExt;
|
||||||
@@ -19,16 +20,41 @@ use crate::Error;
|
|||||||
|
|
||||||
/// The ABI version libkrun reports for a working `/dev/kvm`
|
/// The ABI version libkrun reports for a working `/dev/kvm`
|
||||||
/// (`KVM_GET_API_VERSION`, the first request of the KVMIO range).
|
/// (`KVM_GET_API_VERSION`, the first request of the KVMIO range).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
const KVM_GET_API_VERSION: libc::c_ulong = 0xAE00;
|
const KVM_GET_API_VERSION: libc::c_ulong = 0xAE00;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
const KVM_API_VERSION_EXPECTED: libc::c_int = 12;
|
const KVM_API_VERSION_EXPECTED: libc::c_int = 12;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub const SONAME: &str = "libkrun.1.dylib";
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
pub const SONAME: &str = "libkrun.so.1";
|
pub const SONAME: &str = "libkrun.so.1";
|
||||||
|
|
||||||
/// Remediation shown when libkrun cannot be loaded: distro packaging is
|
/// Remediation shown when libkrun cannot be loaded: on macOS the
|
||||||
/// still sparse (Fedora carries it; Debian and Ubuntu do not yet).
|
/// official Homebrew tap bottles the library; Linux distro packaging
|
||||||
|
/// is still sparse (Fedora carries it; Debian and Ubuntu do not yet).
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const LOAD_REMEDIATION: &str = "install it with `brew install libkrun/krun/libkrun`, \
|
||||||
|
or point DYLD_LIBRARY_PATH at the directory holding it";
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
const LOAD_REMEDIATION: &str = "install libkrun, or point LD_LIBRARY_PATH \
|
const LOAD_REMEDIATION: &str = "install libkrun, or point LD_LIBRARY_PATH \
|
||||||
at the directory holding it (a snap stages it next to the binary)";
|
at the directory holding it (a snap stages it next to the binary)";
|
||||||
|
|
||||||
|
/// The paths dlopen is tried against, in order. A bare soname relies
|
||||||
|
/// on the loader's own search (LD_LIBRARY_PATH on Linux; on macOS it
|
||||||
|
/// never reaches the Homebrew prefix, so the keg paths are named).
|
||||||
|
fn load_candidates() -> Vec<std::path::PathBuf> {
|
||||||
|
let mut candidates = vec![std::path::PathBuf::from(SONAME)];
|
||||||
|
for prefix in crate::utils::BREW_PREFIXES {
|
||||||
|
candidates.push(
|
||||||
|
std::path::Path::new(prefix)
|
||||||
|
.join("opt/libkrun/lib")
|
||||||
|
.join(SONAME),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
macro_rules! symbol {
|
macro_rules! symbol {
|
||||||
($lib:expr, $name:expr, $ty:ty) => {
|
($lib:expr, $name:expr, $ty:ty) => {
|
||||||
symbol!($lib, $name, $ty, "libkrun too old?")
|
symbol!($lib, $name, $ty, "libkrun too old?")
|
||||||
@@ -65,11 +91,24 @@ pub struct Krun {
|
|||||||
impl Krun {
|
impl Krun {
|
||||||
/// dlopen libkrun, failing with the install remediation.
|
/// dlopen libkrun, failing with the install remediation.
|
||||||
pub fn load() -> Result<Self> {
|
pub fn load() -> Result<Self> {
|
||||||
// Safety: dlopen of a fixed SONAME; the handle lives in `self`
|
let candidates = load_candidates();
|
||||||
// and every symbol below is used through it.
|
let mut last: Option<anyhow::Error> = None;
|
||||||
let lib = unsafe { libloading::Library::new(SONAME) }
|
for candidate in &candidates {
|
||||||
.with_context(|| format!("cannot load {SONAME}: {LOAD_REMEDIATION}"))?;
|
// Safety: dlopen of a fixed name; the handle lives in
|
||||||
Ok(Krun { lib })
|
// `self` and every symbol below is used through it.
|
||||||
|
let lib = unsafe { libloading::Library::new(candidate) };
|
||||||
|
match lib {
|
||||||
|
Ok(lib) => return Ok(Krun { lib }),
|
||||||
|
Err(err) => last = Some(anyhow::Error::new(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tried = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
Err(last.unwrap_or_else(|| anyhow::anyhow!("no library name to try")))
|
||||||
|
.with_context(|| format!("cannot load {SONAME} (tried {tried}): {LOAD_REMEDIATION}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `krun_set_log_level`
|
/// `krun_set_log_level`
|
||||||
@@ -327,9 +366,12 @@ fn path_cstring(path: &Path) -> Result<CString> {
|
|||||||
.with_context(|| format!("path contains a NUL: {}", path.display()))
|
.with_context(|| format!("path contains a NUL: {}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe KVM the crun way: open `/dev/kvm` read-write, then check
|
/// Probe the host hypervisor. On Linux that is KVM: open `/dev/kvm`
|
||||||
/// `KVM_GET_API_VERSION` returns 12.
|
/// read-write, then check `KVM_GET_API_VERSION` returns 12. On macOS
|
||||||
pub fn kvm_probe() -> Result<()> {
|
/// libkrun runs on the Hypervisor.framework, which exists on Apple
|
||||||
|
/// Silicon Macs and nothing else.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn hypervisor_probe() -> Result<()> {
|
||||||
use std::os::unix::io::AsRawFd;
|
use std::os::unix::io::AsRawFd;
|
||||||
let file = std::fs::OpenOptions::new()
|
let file = std::fs::OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
@@ -355,10 +397,102 @@ pub fn kvm_probe() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Hypervisor.framework check, in two steps. `sysctl
|
||||||
|
/// kern.hv_support` says the machine has the framework; a real
|
||||||
|
/// `hv_vm_create` says this process may use it — on current macOS the
|
||||||
|
/// entitlement check inside the framework is what kills most
|
||||||
|
/// processes, and the denial used to surface only as an opaque
|
||||||
|
/// `krun_start_enter` EINVAL after a full image build.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn hypervisor_probe() -> Result<()> {
|
||||||
|
// libkrun's macOS backend is HVF and arm64-only; there is no
|
||||||
|
// build for Intel Macs to probe for.
|
||||||
|
#[cfg(not(target_arch = "aarch64"))]
|
||||||
|
{
|
||||||
|
return Err(anyhow::Error::new(Error::HvUnavailable(
|
||||||
|
std::io::Error::other(
|
||||||
|
"libkrun ships no macOS backend for Intel Macs; an Apple Silicon host is required",
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
#[cfg(target_arch = "aarch64")]
|
||||||
|
{
|
||||||
|
let mut supported: libc::c_int = 0;
|
||||||
|
let mut len = std::mem::size_of_val(&supported);
|
||||||
|
// Safety: sysctlbyname with a fixed name and an int-sized
|
||||||
|
// output buffer; the kernel writes only into `supported`.
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::sysctlbyname(
|
||||||
|
c"kern.hv_support".as_ptr(),
|
||||||
|
(&mut supported as *mut libc::c_int).cast(),
|
||||||
|
&mut len,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
return Err(anyhow::Error::new(Error::HvUnavailable(err))
|
||||||
|
.context("reading sysctl kern.hv_support"));
|
||||||
|
}
|
||||||
|
if supported != 1 {
|
||||||
|
return Err(anyhow::Error::new(Error::HvUnavailable(
|
||||||
|
std::io::Error::other("this Mac reports no Hypervisor.framework support"),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
hypervisor_entitlement_probe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create and destroy a real framework VM. `HV_DENIED` is the denial
|
||||||
|
/// every macOS packaging step has to design around: the framework
|
||||||
|
/// grants VM creation only to binaries signed with the
|
||||||
|
/// `com.apple.security.hypervisor` entitlement, so the fix is a
|
||||||
|
/// re-sign, not a system change. The framework is dlopen'd by
|
||||||
|
/// absolute path — ecrou links nothing, and the path is stable across
|
||||||
|
/// releases; if it cannot be opened at all, the probe defers to the
|
||||||
|
/// engine instead of inventing a verdict.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn hypervisor_entitlement_probe() -> Result<()> {
|
||||||
|
const HV_DENIED: u32 = 0xfae94007;
|
||||||
|
let framework =
|
||||||
|
std::path::Path::new("/System/Library/Frameworks/Hypervisor.framework/Hypervisor");
|
||||||
|
// Safety: dlopen of a fixed absolute framework path; symbols are
|
||||||
|
// resolved immediately and the handle lives for this function.
|
||||||
|
let lib = match unsafe { libloading::Library::new(framework) } {
|
||||||
|
Ok(lib) => lib,
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
};
|
||||||
|
let create = symbol!(
|
||||||
|
&lib,
|
||||||
|
b"hv_vm_create\0",
|
||||||
|
unsafe extern "C" fn(*const std::ffi::c_void) -> u32
|
||||||
|
);
|
||||||
|
let destroy = symbol!(&lib, b"hv_vm_destroy\0", unsafe extern "C" fn() -> u32);
|
||||||
|
// Safety: plain calls into the framework; the VM is destroyed
|
||||||
|
// right away, or never created.
|
||||||
|
let ret = unsafe { create(std::ptr::null()) };
|
||||||
|
if ret == 0 {
|
||||||
|
unsafe { destroy() };
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let reason = if ret == HV_DENIED {
|
||||||
|
"the framework denied the process (HV_DENIED): sign the binary with the \
|
||||||
|
com.apple.security.hypervisor entitlement, the way packaging does — \
|
||||||
|
`codesign --force --sign - --entitlements <plist> <binary>`"
|
||||||
|
} else {
|
||||||
|
"the framework refused VM creation"
|
||||||
|
};
|
||||||
|
Err(anyhow::Error::new(Error::HvUnavailable(
|
||||||
|
std::io::Error::other(format!("{reason} (hv error 0x{ret:x})")),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
#[test]
|
#[test]
|
||||||
fn kvm_probe_constant_matches_kvm_abi() {
|
fn kvm_probe_constant_matches_kvm_abi() {
|
||||||
// KVMIO base 0xAE; KVM_GET_API_VERSION is the first request.
|
// KVMIO base 0xAE; KVM_GET_API_VERSION is the first request.
|
||||||
@@ -366,9 +500,10 @@ mod tests {
|
|||||||
assert_eq!(KVM_API_VERSION_EXPECTED, 12);
|
assert_eq!(KVM_API_VERSION_EXPECTED, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
#[test]
|
#[test]
|
||||||
fn kvm_probe_reports_unavailability_or_works() {
|
fn kvm_probe_reports_unavailability_or_works() {
|
||||||
match kvm_probe() {
|
match hypervisor_probe() {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let msg = format!("{err:#}");
|
let msg = format!("{err:#}");
|
||||||
@@ -378,6 +513,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn hypervisor_probe_reports_unavailability_or_works() {
|
||||||
|
match hypervisor_probe() {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(err) => {
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("Hypervisor.framework"),
|
||||||
|
"unhelpful probe error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_fails_with_remediation_or_round_trips() {
|
fn load_fails_with_remediation_or_round_trips() {
|
||||||
match Krun::load() {
|
match Krun::load() {
|
||||||
@@ -389,14 +539,28 @@ mod tests {
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
let msg = format!("{err:#}");
|
let msg = format!("{err:#}");
|
||||||
assert!(msg.contains(SONAME), "bad load error: {msg}");
|
assert!(msg.contains(SONAME), "bad load error: {msg}");
|
||||||
assert!(
|
// The remediation names the platform's install path:
|
||||||
msg.contains("LD_LIBRARY_PATH"),
|
// the loader variable on Linux, the brew tap on macOS.
|
||||||
"remediation missing: {msg}"
|
let hint = if cfg!(target_os = "macos") {
|
||||||
);
|
"brew install"
|
||||||
|
} else {
|
||||||
|
"LD_LIBRARY_PATH"
|
||||||
|
};
|
||||||
|
assert!(msg.contains(hint), "remediation missing: {msg}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_candidates_start_with_the_bare_soname() {
|
||||||
|
assert_eq!(load_candidates()[0], std::path::PathBuf::from(SONAME));
|
||||||
|
// The brew keg paths exist only as candidates: dlopen tries
|
||||||
|
// them in order and they are listed even when absent.
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
assert!(load_candidates().len() > 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ptr_array_is_null_terminated() {
|
fn ptr_array_is_null_terminated() {
|
||||||
let args = CArgs::build(&["a".to_string(), "b".to_string()]).unwrap();
|
let args = CArgs::build(&["a".to_string(), "b".to_string()]).unwrap();
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
//! preparation) and [`vm`] (running a command inside a prepared
|
//! preparation) and [`vm`] (running a command inside a prepared
|
||||||
//! rootfs). Public functions return [`Result`] carrying the [`Error`]
|
//! rootfs). Public functions return [`Result`] carrying the [`Error`]
|
||||||
//! enum, so consumers can match on host-readiness failures
|
//! enum, so consumers can match on host-readiness failures
|
||||||
//! ([`Error::KvmUnavailable`], [`Error::ArchNotAvailable`]) versus
|
//! ([`Error::KvmUnavailable`], [`Error::HvUnavailable`],
|
||||||
//! ordinary failures ([`Error::Other`], which preserves the full context
|
//! [`Error::ArchNotAvailable`]) versus ordinary failures
|
||||||
//! chain).
|
//! ([`Error::Other`], which preserves the full context chain).
|
||||||
//!
|
//!
|
||||||
//! ## Crate split
|
//! ## Crate split
|
||||||
//!
|
//!
|
||||||
|
|||||||
+18
-12
@@ -405,10 +405,8 @@ mod tests {
|
|||||||
use crate::progress::NoProgress;
|
use crate::progress::NoProgress;
|
||||||
|
|
||||||
fn have_e2fsprogs() -> bool {
|
fn have_e2fsprogs() -> bool {
|
||||||
std::process::Command::new("mkfs.ext4")
|
crate::utils::find_tool("mkfs.ext4").is_some()
|
||||||
.arg("-V")
|
&& crate::utils::find_tool("debugfs").is_some()
|
||||||
.output()
|
|
||||||
.is_ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -447,9 +445,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
tar.into_inner().unwrap().finish().unwrap();
|
tar.into_inner().unwrap().finish().unwrap();
|
||||||
|
|
||||||
let req = PrepareRequest::new(
|
let mut req = PrepareRequest::new(
|
||||||
"ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
"ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
);
|
);
|
||||||
|
req.arch = "amd64".to_string();
|
||||||
let pulled = cache.pull(&req, &NoProgress).unwrap();
|
let pulled = cache.pull(&req, &NoProgress).unwrap();
|
||||||
assert!(!pulled.downloaded);
|
assert!(!pulled.downloaded);
|
||||||
assert_eq!(pulled.cache_path, entry);
|
assert_eq!(pulled.cache_path, entry);
|
||||||
@@ -474,9 +473,10 @@ mod tests {
|
|||||||
// real pull flow.
|
// real pull flow.
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let cache = RootfsCache::new(dir.path().to_path_buf());
|
let cache = RootfsCache::new(dir.path().to_path_buf());
|
||||||
let req = PrepareRequest::new(
|
let mut req = PrepareRequest::new(
|
||||||
"ubuntu@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
"ubuntu@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||||
);
|
);
|
||||||
|
req.arch = "amd64".to_string();
|
||||||
// A pinned OCI source resolves without the network, so
|
// A pinned OCI source resolves without the network, so
|
||||||
// parse_image_ref succeeds; the entry is absent, so pull takes
|
// parse_image_ref succeeds; the entry is absent, so pull takes
|
||||||
// the download path and fails in fill_entry (no registry
|
// the download path and fails in fill_entry (no registry
|
||||||
@@ -502,7 +502,8 @@ mod tests {
|
|||||||
touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz");
|
touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz");
|
||||||
touch_entry(&cache, "alpine-3.22.9-x86_64.tar.gz");
|
touch_entry(&cache, "alpine-3.22.9-x86_64.tar.gz");
|
||||||
|
|
||||||
let req = PrepareRequest::new("alpine:3.23");
|
let mut req = PrepareRequest::new("alpine:3.23");
|
||||||
|
req.arch = "amd64".to_string();
|
||||||
let (source, path, downloaded) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
|
let (source, path, downloaded) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
|
||||||
assert!(!downloaded);
|
assert!(!downloaded);
|
||||||
assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz"));
|
assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz"));
|
||||||
@@ -519,7 +520,8 @@ mod tests {
|
|||||||
touch_entry(&cache, "alpine-3.20.3-x86_64.tar.gz");
|
touch_entry(&cache, "alpine-3.20.3-x86_64.tar.gz");
|
||||||
touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz");
|
touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz");
|
||||||
|
|
||||||
let req = PrepareRequest::new("alpine:3.23");
|
let mut req = PrepareRequest::new("alpine:3.23");
|
||||||
|
req.arch = "amd64".to_string();
|
||||||
let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
|
let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
|
||||||
assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz"));
|
assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz"));
|
||||||
}
|
}
|
||||||
@@ -531,13 +533,15 @@ mod tests {
|
|||||||
touch_entry(&cache, "alpine-20250401-x86_64.tar.gz");
|
touch_entry(&cache, "alpine-20250401-x86_64.tar.gz");
|
||||||
touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz");
|
touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz");
|
||||||
|
|
||||||
let req = PrepareRequest::new("alpine:edge");
|
let mut req = PrepareRequest::new("alpine:edge");
|
||||||
|
req.arch = "amd64".to_string();
|
||||||
let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
|
let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
|
||||||
assert_eq!(path, dir.path().join("alpine-20250401-x86_64.tar.gz"));
|
assert_eq!(path, dir.path().join("alpine-20250401-x86_64.tar.gz"));
|
||||||
|
|
||||||
// Plain "alpine" floats among releases; the edge date is
|
// Plain "alpine" floats among releases; the edge date is
|
||||||
// invisible to it.
|
// invisible to it.
|
||||||
let req = PrepareRequest::new("alpine");
|
let mut req = PrepareRequest::new("alpine");
|
||||||
|
req.arch = "amd64".to_string();
|
||||||
let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
|
let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
|
||||||
assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz"));
|
assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz"));
|
||||||
}
|
}
|
||||||
@@ -549,7 +553,8 @@ mod tests {
|
|||||||
touch_entry(&cache, "ubuntu-24.04-amd64.tar.gz");
|
touch_entry(&cache, "ubuntu-24.04-amd64.tar.gz");
|
||||||
touch_entry(&cache, "ubuntu-26.04-amd64.tar.gz");
|
touch_entry(&cache, "ubuntu-26.04-amd64.tar.gz");
|
||||||
|
|
||||||
let req = PrepareRequest::new("ubuntu:lts");
|
let mut req = PrepareRequest::new("ubuntu:lts");
|
||||||
|
req.arch = "amd64".to_string();
|
||||||
let (source, _, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
|
let (source, _, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
|
||||||
let ImageSource::DirectTarball { version, .. } = source else {
|
let ImageSource::DirectTarball { version, .. } = source else {
|
||||||
panic!("expected a direct tarball");
|
panic!("expected a direct tarball");
|
||||||
@@ -599,7 +604,8 @@ mod tests {
|
|||||||
// tree itself: the marker is read back out of the image.
|
// tree itself: the marker is read back out of the image.
|
||||||
let image = rootfs.dir().join("rootfs.img");
|
let image = rootfs.dir().join("rootfs.img");
|
||||||
assert!(image.exists());
|
assert!(image.exists());
|
||||||
let out = std::process::Command::new("debugfs")
|
let debugfs = crate::utils::find_tool("debugfs").unwrap();
|
||||||
|
let out = std::process::Command::new(debugfs)
|
||||||
.arg("-R")
|
.arg("-R")
|
||||||
.arg("cat /etc-marker")
|
.arg("cat /etc-marker")
|
||||||
.arg(&image)
|
.arg(&image)
|
||||||
|
|||||||
@@ -10,6 +10,54 @@ use anyhow::{anyhow, Context};
|
|||||||
use crate::krun::Krun;
|
use crate::krun::Krun;
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
/// The macOS stand-in for `prctl(PR_SET_PDEATHSIG)`: a thread in the
|
||||||
|
/// child registers a kqueue filter on the parent pid (EVFILT_PROC,
|
||||||
|
/// NOTE_EXIT) and kills this process with SIGKILL when the parent
|
||||||
|
/// exits. The kernel fires the filter for an already-dead pid's
|
||||||
|
/// registration too, and the getppid check catches the window where
|
||||||
|
/// the parent died before registration; SIGKILL itself cannot be
|
||||||
|
/// caught, which keeps the child's fate identical to Linux's.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn watch_parent_or_die() {
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
// Safety: kqueue/kevent on self-owned descriptors; the kevent
|
||||||
|
// structs are plain value types the kernel fills in.
|
||||||
|
unsafe {
|
||||||
|
let kq = libc::kqueue();
|
||||||
|
if kq < 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let change = libc::kevent {
|
||||||
|
ident: libc::getppid() as usize,
|
||||||
|
filter: libc::EVFILT_PROC,
|
||||||
|
flags: libc::EV_ADD,
|
||||||
|
fflags: libc::NOTE_EXIT,
|
||||||
|
data: 0,
|
||||||
|
udata: std::ptr::null_mut(),
|
||||||
|
};
|
||||||
|
let n = libc::kevent(kq, &change, 1, std::ptr::null_mut(), 0, std::ptr::null());
|
||||||
|
// A failed registration or an orphaned ppid means the
|
||||||
|
// parent is already gone.
|
||||||
|
if n != 0 || libc::getppid() == 1 {
|
||||||
|
libc::kill(libc::getpid(), libc::SIGKILL);
|
||||||
|
}
|
||||||
|
let mut event: libc::kevent = std::mem::zeroed();
|
||||||
|
loop {
|
||||||
|
let n = libc::kevent(kq, std::ptr::null(), 0, &mut event, 1, std::ptr::null());
|
||||||
|
if n > 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if n < 0
|
||||||
|
&& std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
libc::kill(libc::getpid(), libc::SIGKILL);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Fork the machine process and enter the VM loop inside it. The
|
/// Fork the machine process and enter the VM loop inside it. The
|
||||||
/// child only touches libkrun configuration before entering the VM
|
/// child only touches libkrun configuration before entering the VM
|
||||||
/// loop, which never returns: a normal payload exit ends the child
|
/// loop, which never returns: a normal payload exit ends the child
|
||||||
@@ -23,7 +71,7 @@ pub(crate) fn spawn_machine(
|
|||||||
let child = unsafe { libc::fork() };
|
let child = unsafe { libc::fork() };
|
||||||
if child == 0 {
|
if child == 0 {
|
||||||
// Safety: after fork the child runs no threads of its own; the
|
// Safety: after fork the child runs no threads of its own; the
|
||||||
// prctl/exit pair has no unwinding preconditions.
|
// kill/exit pair has no unwinding preconditions.
|
||||||
let bail = |msg: String, code: i32| -> ! {
|
let bail = |msg: String, code: i32| -> ! {
|
||||||
// The console file is the channel the parent streams: a
|
// The console file is the channel the parent streams: a
|
||||||
// boot failure belongs in it, next to whatever the guest
|
// boot failure belongs in it, next to whatever the guest
|
||||||
@@ -37,8 +85,16 @@ pub(crate) fn spawn_machine(
|
|||||||
eprintln!("Error: {msg}");
|
eprintln!("Error: {msg}");
|
||||||
unsafe { libc::_exit(code) }
|
unsafe { libc::_exit(code) }
|
||||||
};
|
};
|
||||||
|
// The parent-death contract, per platform: Linux delivers
|
||||||
|
// SIGKILL through PR_SET_PDEATHSIG; macOS has no prctl, so
|
||||||
|
// a watcher thread follows the parent through a kqueue and
|
||||||
|
// kills this process when the parent goes away.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
|
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
watch_parent_or_die();
|
||||||
// Headless only: unwire the console input (with a tty on
|
// Headless only: unwire the console input (with a tty on
|
||||||
// fd 0 libkrun puts that terminal into raw mode, which
|
// fd 0 libkrun puts that terminal into raw mode, which
|
||||||
// fights whoever owns it, and stops a background caller
|
// fights whoever owns it, and stops a background caller
|
||||||
@@ -46,6 +102,7 @@ pub(crate) fn spawn_machine(
|
|||||||
// path and still forwards a Ctrl-C sent to the process
|
// path and still forwards a Ctrl-C sent to the process
|
||||||
// group into the guest. An interactive machine wants the
|
// group into the guest. An interactive machine wants the
|
||||||
// opposite: fd 0 is its console input by design.
|
// opposite: fd 0 is its console input by design.
|
||||||
|
unsafe {
|
||||||
if !interactive {
|
if !interactive {
|
||||||
let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDWR);
|
let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDWR);
|
||||||
if null >= 0 {
|
if null >= 0 {
|
||||||
|
|||||||
@@ -160,6 +160,96 @@ pub fn map_oci_arch(arch: &str) -> String {
|
|||||||
Arch::from_str(arch).oci_name().to_string()
|
Arch::from_str(arch).oci_name().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// External tool lookup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// The Homebrew prefixes the lookup falls back to. Empty off macOS:
|
||||||
|
/// Homebrew does not exist there and PATH is the whole story.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub const BREW_PREFIXES: &[&str] = &["/opt/homebrew", "/usr/local"];
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub const BREW_PREFIXES: &[&str] = &[];
|
||||||
|
|
||||||
|
/// The keg-only formulae the run's external tools come from, in the
|
||||||
|
/// order they should be preferred. e2fsprogs is keg-only in Homebrew
|
||||||
|
/// (its sbin never reaches PATH), and the tap ships its own variant
|
||||||
|
/// built with libarchive, which outranks the core bottle.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const BREW_TOOL_FORMULAE: &[&str] = &["e2fsprogs-libarchive", "e2fsprogs"];
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
const BREW_TOOL_FORMULAE: &[&str] = &[];
|
||||||
|
|
||||||
|
/// Locate an external tool the run shells out to: PATH first, then
|
||||||
|
/// the Homebrew keg directories. Returns the path, so callers spawn a
|
||||||
|
/// deterministic binary instead of a whatever-came-first `Command`.
|
||||||
|
pub fn find_tool(name: &str) -> Option<PathBuf> {
|
||||||
|
if let Some(paths) = std::env::var_os("PATH") {
|
||||||
|
for dir in std::env::split_paths(&paths) {
|
||||||
|
let candidate = dir.join(name);
|
||||||
|
if is_executable(&candidate) {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for prefix in BREW_PREFIXES {
|
||||||
|
for formula in BREW_TOOL_FORMULAE {
|
||||||
|
for sub in ["sbin", "bin"] {
|
||||||
|
let candidate = Path::new(prefix)
|
||||||
|
.join("opt")
|
||||||
|
.join(formula)
|
||||||
|
.join(sub)
|
||||||
|
.join(name);
|
||||||
|
if is_executable(&candidate) {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_executable(path: &Path) -> bool {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::metadata(path)
|
||||||
|
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The brew kegs whose dylibs are loaded by bare soname at runtime:
|
||||||
|
/// libkrunfw (loaded by libkrun) and libarchive (loaded by a
|
||||||
|
/// libarchive-capable mke2fs). dyld's fallback search never reaches
|
||||||
|
/// the brew prefix, so these directories have to land in
|
||||||
|
/// DYLD_FALLBACK_LIBRARY_PATH before any of those loads happen.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const DYLD_KEGS: &[&str] = &["libkrunfw", "libarchive"];
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
const DYLD_KEGS: &[&str] = &[];
|
||||||
|
|
||||||
|
/// Directories the bare-soname dlopens can be satisfied from, joined
|
||||||
|
/// for DYLD_FALLBACK_LIBRARY_PATH, or None when none of them exists.
|
||||||
|
pub fn dyld_fallback_dirs() -> Option<String> {
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
for prefix in BREW_PREFIXES {
|
||||||
|
for keg in DYLD_KEGS {
|
||||||
|
let dir = Path::new(prefix).join("opt").join(keg).join("lib");
|
||||||
|
if dir.is_dir() {
|
||||||
|
dirs.push(dir.into_os_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dirs.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
std::env::join_paths(dirs)
|
||||||
|
.ok()?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! payload with live stdio, propagate its exit code, throw the machine
|
//! payload with live stdio, propagate its exit code, throw the machine
|
||||||
//! away.
|
//! away.
|
||||||
//!
|
//!
|
||||||
//! The flow is: KVM probe, rootfs prepare (cache-aware download and
|
//! The flow is: hypervisor probe, rootfs prepare (cache-aware download and
|
||||||
//! extraction), guest init written into the scratch rootfs, libkrun
|
//! extraction), guest init written into the scratch rootfs, libkrun
|
||||||
//! context wired to it, `krun_start_enter` blocking on this thread, then
|
//! context wired to it, `krun_start_enter` blocking on this thread, then
|
||||||
//! the control file read back for the exit code. The VMM runs inside
|
//! the control file read back for the exit code. The VMM runs inside
|
||||||
@@ -142,7 +142,7 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
krun::kvm_probe()?;
|
krun::hypervisor_probe()?;
|
||||||
let krun = Krun::load()?;
|
let krun = Krun::load()?;
|
||||||
|
|
||||||
let ram_mib = parse_memory_mib(&options.memory).map_err(Error::Other)?;
|
let ram_mib = parse_memory_mib(&options.memory).map_err(Error::Other)?;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# The formula lives in the repo and ships through the
|
||||||
|
# vhaudiquet/homebrew-ecrou tap; it needs the same tap libkrun's
|
||||||
|
# krunkit comes from, because the engine is dlopen'd from those kegs
|
||||||
|
# at runtime.
|
||||||
|
class Ecrou < Formula
|
||||||
|
desc "a small microVM runtime based on libkrun"
|
||||||
|
homepage "https://github.com/vhaudiquet/ecrou"
|
||||||
|
# Placeholder pin: the release workflow rewrites url and sha256 on publish.
|
||||||
|
url "https://github.com/vhaudiquet/ecrou.git", tag: "v0.1.0"
|
||||||
|
license "MIT"
|
||||||
|
|
||||||
|
# libkrun's Hypervisor.framework backend is arm64-only upstream.
|
||||||
|
depends_on arch: :arm64
|
||||||
|
depends_on "libkrun/homebrew-krun/libkrun"
|
||||||
|
depends_on "e2fsprogs"
|
||||||
|
# A libarchive-capable mke2fs populates ext4 images from tarballs in
|
||||||
|
# one pass; without it the build falls back to a per-file tree walk.
|
||||||
|
depends_on "libarchive"
|
||||||
|
|
||||||
|
def install
|
||||||
|
# CI signed the binary with the hypervisor entitlement (a hard
|
||||||
|
# requirement on current macOS, the same one krunkit's formula
|
||||||
|
# signs for); a rebuild here would strip the signature, so the
|
||||||
|
# release tarball's binary is poured as-is.
|
||||||
|
bin.install "ecrou"
|
||||||
|
end
|
||||||
|
|
||||||
|
def caveats
|
||||||
|
<<~EOS
|
||||||
|
The binary is ad-hoc signed with the com.apple.security.hypervisor
|
||||||
|
entitlement. Rebuilding from source (cargo) requires re-signing:
|
||||||
|
codesign --force --sign - --entitlements packaging/homebrew/ecrou.entitlements target/release/ecrou
|
||||||
|
`ecrou probe` reports this when the entitlement is missing.
|
||||||
|
EOS
|
||||||
|
end
|
||||||
|
|
||||||
|
test do
|
||||||
|
system bin/"ecrou", "probe"
|
||||||
|
output = shell_output("#{bin}/ecrou run --quiet alpine -- uname -r").strip
|
||||||
|
assert_match(/\d+\.\d+/, output)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict>
|
||||||
|
<key>com.apple.security.hypervisor</key><true/>
|
||||||
|
</dict></plist>
|
||||||
Reference in new issue
Block a user