feat(rootfs): cross the rootfs as an ext4 image on a block device

The rootfs was a virtiofs share over an extracted tree, and that
showed: the tree's owners were the host user's, because an
unprivileged extract cannot chown to a tar's uid, and every chown
inside the machine failed with EPERM against the host side.

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

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

extract.rs keeps what the image build consumes (the tar decoder and
the OCI merge) and drops the extraction-to-disk path it once carried;
the module is private now. The engine needs the blk feature from here
on: the snap and the CI engine build follow in their own commits.
This commit is contained in:
vhaudiquet committed 2026-10-30 19:10:48 +01:00
1 parent 8cc3ba9854
commit 51f56116a3
11 files changed
+1032 -957

No files matched your search

+4 -2
View File
@@ -38,10 +38,12 @@ Rules:
(`crates/ecrou/src/rootfs.rs`)
- `cache` - the rootfs entry store: naming, locking, freshness,
layer pointers (`crates/ecrou/src/cache.rs`)
- `pack` - rootfs tarball packing (`crates/ecrou/src/pack.rs`)
- `image` - ext4 image building and inspection
(`crates/ecrou/src/image.rs`)
- `distro` - distro definitions and mirrors (`crates/ecrou/src/distro.rs`)
- `download` - image download (`crates/ecrou/src/download.rs`)
- `extract` - rootfs extraction (`crates/ecrou/src/extract.rs`)
- `extract` - tar decoding and the OCI layer merge feeding the image
build (`crates/ecrou/src/extract.rs`)
- `registry` - OCI registry client (`crates/ecrou/src/registry.rs`)
- `utils`, `error`, `progress` - shared helpers
- `deps` - dependency additions/bumps (manifests, lockfile)
+24 -58
View File
@@ -13,10 +13,8 @@ use anyhow::{anyhow, Context};
use crate::distro::{map_arch, Distro, ImageSource};
use crate::download::digest_sidecar;
use crate::extract::extract_tarball;
use crate::progress::Progress;
use crate::registry::fetch_oci_digest;
use crate::rootfs::PreparedRootfs;
use crate::Result;
/// Whether an OCI image with a floating tag (":latest") has moved since
@@ -135,66 +133,37 @@ impl RootfsCache {
}
}
pub(crate) fn extract_prepared(
source: &Path,
progress: &dyn Progress,
) -> anyhow::Result<PreparedRootfs> {
let temp = tempfile::tempdir().context("Failed to create temporary directory")?;
let rootfs = temp.path().to_path_buf();
log::debug!("Extracting to: {}", rootfs.display());
extract_tarball(source, &rootfs, progress)?;
provision_resolver(&rootfs);
Ok(PreparedRootfs { rootfs: temp })
}
/// Give the guest the host's resolver: the engine proxies guest
/// sockets to the host's stack, so a nameserver line that works on the
/// host works in the machine, loopback stub included. Without it the
/// guest has whatever the image shipped, which is usually nothing.
/// Best effort: a rootfs with its own resolver config keeps it.
fn provision_resolver(rootfs: &Path) {
// The engine proxies guest sockets to the host's stack, so any
// host-reachable nameserver works in the machine - but loopback
// addresses do not cross: 127.0.0.53 stays inside the guest, where
// nothing listens. So the source of truth is systemd-resolved's
// uplink config when present, and loopback nameservers are dropped
// from whatever is copied. Best effort: failures leave the
// image's file.
let host = match std::fs::read_to_string("/run/systemd/resolve/resolv.conf")
/// The host resolver config, ready to provision into the machine: the
/// engine proxies guest sockets to the host's stack, so a nameserver
/// line that works on the host works in the machine, loopback stub
/// included. Without it the guest has whatever the image shipped,
/// which is usually nothing. Best effort: None leaves the image's own
/// file standing.
pub(crate) fn host_resolver_content() -> Option<String> {
// Loopback addresses do not cross: 127.0.0.53 stays inside the
// guest, where nothing listens. The source of truth is
// systemd-resolved's uplink config when present, and loopback
// nameservers are dropped from whatever is copied.
match std::fs::read_to_string("/run/systemd/resolve/resolv.conf")
.or_else(|_| std::fs::read_to_string("/etc/resolv.conf"))
{
Ok(s) if !s.trim().is_empty() => drop_loopback_nameservers(&s),
Ok(s) if !s.trim().is_empty() => {
let content = drop_loopback_nameservers(&s);
if content.contains("nameserver ") {
Some(content)
} else {
log::debug!("host resolver config has no usable nameserver; leaving the image's");
None
}
}
Ok(_) => {
log::debug!("host resolver config is empty; leaving the image's in place");
return;
None
}
Err(e) => {
log::debug!(
"no host resolver config ({}); leaving the image's in place",
e
);
return;
log::debug!("no host resolver config ({e}); leaving the image's in place");
None
}
};
if !host.contains("nameserver ") {
log::debug!("host resolver config has no usable nameserver; leaving the image's");
return;
}
let target = rootfs.join("etc/resolv.conf");
if let Err(e) = std::fs::remove_file(&target) {
if e.kind() != std::io::ErrorKind::NotFound {
log::debug!("could not clear {}: {}", target.display(), e);
return;
}
}
if let Some(parent) = target.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&target, host) {
Ok(()) => log::debug!("provisioned a resolver into {}", target.display()),
Err(e) => log::debug!("could not write {}: {}", target.display(), e),
}
}
@@ -335,9 +304,6 @@ mod tests {
);
}
/// Build a minimal rootfs tree with a nested executable and a symlink,
/// pack it, and extract it back through the public extract_tarball path.
#[test]
fn a_digest_pin_names_its_own_cache_entry() {
let cache = RootfsCache::new(std::path::PathBuf::from("/cache"));
File diff suppressed because it is too large. Load diff
+405
View File
@@ -0,0 +1,405 @@
//! The ext4 rootfs image: the rootfs crosses the machine boundary as a
//! raw ext4 image mke2fs builds straight from the image tarball, and
//! per-run provisioning rides debugfs. Both tools ship in e2fsprogs; a
//! missing tool is an environment problem, named as one.
//!
//! Building from the tarball instead of an extracted tree is what keeps
//! ownership honest: mke2fs bakes each entry's recorded uid, gid and
//! mode into the new filesystem, with no host-side chown in between to
//! lose them. An unprivileged extract cannot preserve a tar's owners
//! (chown to an arbitrary uid is root's alone); a tarball to image
//! pipeline never needs one.
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{anyhow, Context};
use crate::progress::Progress;
/// Build the rootfs image at `image` from `tarball`.
///
/// An OCI layer bundle merges into one plain tar first, so the whiteout
/// bookkeeping and the verbatim headers happen in one place; a bundle
/// that turns out to carry no manifest builds directly, like any other
/// archive. mkfs consumes gz, xz and zst tarballs natively.
pub(crate) fn build_rootfs_image(
tarball: &Path,
image: &Path,
progress: &dyn Progress,
) -> anyhow::Result<()> {
progress.message("Building the rootfs image");
let name = tarball
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
let mut merged: Option<PathBuf> = None;
if name.starts_with("oci-") {
let side = image.with_extension("oci.tar");
if crate::extract::merge_oci_tarball(tarball, &side, progress)? {
merged = Some(side);
}
}
let source: &Path = merged.as_deref().unwrap_or(tarball);
let result = mkfs_from_tar(source, image);
if let Some(side) = merged {
// The merged archive has value only until mke2fs consumes it;
// a failure to clean up must not mask mkfs's own error.
let _ = std::fs::remove_file(side);
}
result
}
/// Run mke2fs over the archive. The filesystem size and inode count
/// come from a scan of the archive: mke2fs derives neither from a tar.
fn mkfs_from_tar(tarball: &Path, image: &Path) -> anyhow::Result<()> {
let (bytes, entries) = tar_scan(tarball)?;
// Headroom for the journal, the inode tables and block rounding.
// The file is created sparse, so generous headroom costs nothing
// on disk and gives the guest room to install packages.
let fs_bytes = bytes + bytes / 8 + 512 * 1024 * 1024;
// The disk presents in 512-byte sectors: a size in between hides
// its tail from the guest.
let fs_bytes = fs_bytes.div_ceil(4096) * 4096;
// -N pins the inode count: the ratio default does not know the
// archive's file count and comes up short on trees of small files.
let inodes = entries + entries / 4 + 2048;
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(image)
.with_context(|| format!("creating {}", image.display()))?;
file.set_len(fs_bytes)
.with_context(|| format!("sizing {}", image.display()))?;
drop(file);
let out = Command::new("mkfs.ext4")
.args(["-q", "-F", "-N", &inodes.to_string(), "-d"])
.arg(tarball)
.arg(image)
.output()
.map_err(|e| anyhow!("mkfs.ext4 not found ({e}): install e2fsprogs; the snap stages it"))?;
if !out.status.success() {
return Err(anyhow!(
"mkfs.ext4 failed building the rootfs image: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(())
}
/// The uncompressed byte total and entry count of an image tarball.
fn tar_scan(tarball: &Path) -> anyhow::Result<(u64, u64)> {
let mut archive = crate::extract::tar_decoder(tarball)?;
let mut bytes = 0u64;
let mut entries = 0u64;
for entry in archive.entries().context("reading the archive")? {
let entry = entry.context("reading an archive entry")?;
entries += 1;
bytes += entry.header().entry_size().unwrap_or(0);
}
Ok((bytes, entries))
}
/// Copy the cache entry image into the scratch directory. cp reflinks
/// where the filesystem has them and keeps the copy sparse either way:
/// an image occupying 400 MiB must not materialize a GiB of holes.
pub(crate) fn copy_image(src: &Path, dst: &Path) -> anyhow::Result<()> {
let out = Command::new("cp")
.args(["--reflink=auto", "--sparse=auto"])
.arg(src)
.arg(dst)
.output()
.map_err(|e| anyhow!("cp not found ({e}): coreutils is required"))?;
if !out.status.success() {
return Err(anyhow!(
"copying the rootfs image failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(())
}
/// Write files into an image: guest paths unlinked first (a stale
/// control file, a symlink standing where a regular file must), then
/// host files copied in and their modes pinned. One debugfs process
/// does all of it; no mount anywhere. An unlink of an absent path is
/// noise, not an error: the link set is defensive.
pub(crate) fn provision(
image: &Path,
unlinks: &[&str],
files: &[(&Path, &str, u32)],
) -> anyhow::Result<()> {
if files.is_empty() {
return Ok(());
}
let cmds = image.with_extension("provision");
let mut script = String::new();
for guest in unlinks {
script.push_str(&format!("rm {guest}\n"));
}
for (host, guest, mode) in files {
script.push_str(&format!("write {} {guest}\n", host.display()));
// sif mode replaces the whole i_mode, type bits included: a
// bare 0755 leaves an inode with no file type, and the
// kernel's ext4_lookup refuses it as bogus. Every provisioned
// file is regular, so the regular-file type goes in with the
// permission bits.
script.push_str(&format!("sif {guest} mode 0{:o}\n", 0o100000 | mode));
}
std::fs::write(&cmds, script).with_context(|| format!("writing {}", cmds.display()))?;
let out = Command::new("debugfs")
.args(["-w", "-f"])
.arg(&cmds)
.arg(image)
.output()
.map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs; the snap stages it"))?;
let _ = std::fs::remove_file(&cmds);
if !out.status.success() {
return Err(anyhow!(
"debugfs failed provisioning {}: {}",
image.display(),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(())
}
/// A file's content out of the image. debugfs echoes commands and
/// prints a banner on stdout; both start with "debugfs" and the only
/// reader (the exit-code control file) holds an integer, so those
/// lines are dropped. An absent file reads back empty.
pub(crate) fn read_file(image: &Path, guest: &str) -> anyhow::Result<String> {
let out = Command::new("debugfs")
.arg("-R")
.arg(format!("cat {guest}"))
.arg(image)
.output()
.map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs"))?;
let text = String::from_utf8_lossy(&out.stdout);
Ok(text
.lines()
.filter(|l| !l.starts_with("debugfs"))
.collect::<Vec<_>>()
.join("\n"))
}
/// Whether a guest path exists in the image: a debugfs stat that finds
/// an inode. Symlinks resolve; stat follows them like the guest will.
pub(crate) fn stat_exists(image: &Path, guest: &str) -> anyhow::Result<bool> {
let out = Command::new("debugfs")
.arg("-R")
.arg(format!("stat {guest}"))
.arg(image)
.output()
.map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs"))?;
Ok(String::from_utf8_lossy(&out.stdout).contains("Inode:"))
}
/// Which shell the image ships, probed in the image. The order mirrors
/// `utils::detect_shell` over an extracted tree, which the image build
/// replaced; the fallback is the same, and fails in the guest with a
/// clear exec error if the image ships no shell at all.
pub(crate) fn detect_shell(image: &Path) -> anyhow::Result<&'static str> {
for shell in ["/bin/bash", "/bin/sh", "/usr/bin/bash", "/usr/bin/sh"] {
if stat_exists(image, shell)? {
return Ok(shell);
}
}
Ok("/bin/sh")
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::MetadataExt;
fn have_e2fsprogs() -> bool {
Command::new("mkfs.ext4").arg("-V").output().is_ok()
&& Command::new("debugfs").arg("-V").output().is_ok()
}
/// A tarball with one root-owned directory and a file owned by uid
/// 104, the way distro archives record ownership. Gzip when the
/// name says so, so both dispatch paths get exercised.
fn fixture(tarball: &Path) {
let file = std::fs::File::create(tarball).unwrap();
let name = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
// The erased writer keeps one build path; the compression
// comes from the name, exactly like the reader dispatches.
let mut builder = tar::Builder::new(Box::new(if name.ends_with(".tar.gz") {
Box::new(flate2::write::GzEncoder::new(
file,
flate2::Compression::default(),
)) as Box<dyn std::io::Write>
} else {
Box::new(file) as Box<dyn std::io::Write>
}));
let mut dir = tar::Header::new_gnu();
dir.set_size(0);
dir.set_entry_type(tar::EntryType::Directory);
dir.set_mode(0o755);
dir.set_uid(0);
dir.set_gid(0);
builder
.append_data(&mut dir, "etc", std::io::empty())
.unwrap();
let mut file = tar::Header::new_gnu();
file.set_size(2);
file.set_entry_type(tar::EntryType::Regular);
file.set_mode(0o644);
file.set_uid(104);
file.set_gid(104);
builder
.append_data(&mut file, "etc/marker", b"hi" as &[u8])
.unwrap();
builder.finish().unwrap();
}
fn build(tarball: &Path, image: &Path) {
build_rootfs_image(tarball, image, &crate::progress::NoProgress).unwrap();
}
#[test]
fn the_image_carries_the_tarball_ownership() {
if !have_e2fsprogs() {
eprintln!("SKIP: no e2fsprogs");
return;
}
let work = tempfile::tempdir().unwrap();
let tarball = work.path().join("rootfs.tar.gz");
fixture(&tarball);
let image = work.path().join("rootfs.img");
build(&tarball, &image);
let out = Command::new("debugfs")
.arg("-R")
.arg("stat /etc/marker")
.arg(&image)
.output()
.unwrap();
let stat = String::from_utf8_lossy(&out.stdout);
assert!(
stat.lines()
.any(|l| l.contains("User:") && l.contains("104")),
"{stat}"
);
assert!(
stat.lines()
.any(|l| l.contains("Group:") && l.contains("104")),
"{stat}"
);
}
#[test]
fn provisioning_writes_files_and_modes() {
if !have_e2fsprogs() {
eprintln!("SKIP: no e2fsprogs");
return;
}
let work = tempfile::tempdir().unwrap();
let tarball = work.path().join("rootfs.tar");
fixture(&tarball);
let image = work.path().join("rootfs.img");
build(&tarball, &image);
let script = work.path().join("ecrou-init");
std::fs::write(&script, "#!/bin/sh\necho one\n").unwrap();
// The unlink of an absent path is noise: resolv.conf does not
// exist in this fixture, /.ecrou-exit does not either.
provision(
&image,
&["/.ecrou-exit", "/etc/resolv.conf"],
&[(&script, "/ecrou-init", 0o755)],
)
.unwrap();
assert_eq!(
read_file(&image, "/ecrou-init").unwrap(),
"#!/bin/sh\necho one"
);
assert!(stat_exists(&image, "/ecrou-init").unwrap());
assert!(!stat_exists(&image, "/etc/marker-does-not-exist").unwrap());
let out = Command::new("debugfs")
.arg("-R")
.arg("stat /ecrou-init")
.arg(&image)
.output()
.unwrap();
let stat = String::from_utf8_lossy(&out.stdout);
assert!(stat.contains("Mode: 0755"), "{stat}");
}
#[test]
fn a_plain_tarball_builds_the_same_way() {
if !have_e2fsprogs() {
eprintln!("SKIP: no e2fsprogs");
return;
}
let work = tempfile::tempdir().unwrap();
let tarball = work.path().join("rootfs.tar");
fixture(&tarball);
let image = work.path().join("rootfs.img");
build(&tarball, &image);
assert_eq!(read_file(&image, "/etc/marker").unwrap(), "hi");
}
#[test]
fn a_missing_file_reads_back_empty() {
if !have_e2fsprogs() {
eprintln!("SKIP: no e2fsprogs");
return;
}
let work = tempfile::tempdir().unwrap();
let tarball = work.path().join("rootfs.tar");
fixture(&tarball);
let image = work.path().join("rootfs.img");
build(&tarball, &image);
assert_eq!(read_file(&image, "/.ecrou-exit").unwrap(), "");
}
#[test]
fn the_shell_probe_finds_what_the_image_ships() {
if !have_e2fsprogs() {
eprintln!("SKIP: no e2fsprogs");
return;
}
let work = tempfile::tempdir().unwrap();
let tarball = work.path().join("rootfs.tar");
fixture(&tarball);
let image = work.path().join("rootfs.img");
build(&tarball, &image);
// The fixture ships no shell: the fallback, not a probe hit.
assert_eq!(detect_shell(&image).unwrap(), "/bin/sh");
}
#[test]
fn the_scratch_copy_stays_sparse() {
if !have_e2fsprogs() {
eprintln!("SKIP: no e2fsprogs");
return;
}
let work = tempfile::tempdir().unwrap();
let tarball = work.path().join("rootfs.tar");
fixture(&tarball);
let entry = work.path().join("entry.img");
build(&tarball, &entry);
// The headroom truncation is sparse: real blocks stay small.
let real = entry.metadata().unwrap().blocks() * 512;
let apparent = entry.metadata().unwrap().len();
assert!(real < apparent / 2, "real {real}, apparent {apparent}");
let scratch = work.path().join("scratch.img");
copy_image(&entry, &scratch).unwrap();
let copied = scratch.metadata().unwrap();
assert_eq!(copied.len(), apparent);
assert!(copied.blocks() * 512 < apparent / 2);
}
}
+58 -3
View File
@@ -30,13 +30,17 @@ const LOAD_REMEDIATION: &str = "install libkrun, or point LD_LIBRARY_PATH \
at the directory holding it (a snap stages it next to the binary)";
macro_rules! symbol {
($lib:expr, $name:expr, $ty:ty) => {{
($lib:expr, $name:expr, $ty:ty) => {
symbol!($lib, $name, $ty, "libkrun too old?")
};
($lib:expr, $name:expr, $ty:ty, $hint:expr) => {{
// Safety: the symbol is used through the library handle with the
// signature from libkrun.h written at the call site.
unsafe { $lib.get::<$ty>($name) }.with_context(|| {
format!(
"symbol {} missing: libkrun too old?",
String::from_utf8_lossy($name)
"symbol {} missing: {}",
String::from_utf8_lossy($name),
$hint
)
})?
}};
@@ -125,6 +129,57 @@ impl Krun {
krun_ret(unsafe { f(ctx_id, root_c.as_ptr()) }, "krun_set_root")
}
/// `krun_add_disk`: a raw image as a virtio-blk device. An engine
/// built without the blk feature ships no such symbol, and the
/// lookup failure names that, not age.
pub fn add_disk(
&self,
ctx_id: u32,
block_id: &str,
path: &Path,
read_only: bool,
) -> Result<()> {
let block_c = CString::new(block_id).context("block id contains a NUL")?;
let path_c = path_cstring(path)?;
let f = symbol!(
self.lib,
b"krun_add_disk\0",
unsafe extern "C" fn(u32, *const c_char, *const c_char, bool) -> i32,
"the engine was built without the blk feature, so it ships no block devices"
);
// Safety: both CStrings are NUL-terminated and outlive the call.
krun_ret(
unsafe { f(ctx_id, block_c.as_ptr(), path_c.as_ptr(), read_only) },
"krun_add_disk",
)
}
/// `krun_set_root_disk_remount`: which block device the init blob
/// mounts as `/` and chroots into. A disk must have been added
/// first; the mount options stay at their defaults.
pub fn set_root_disk_remount(&self, ctx_id: u32, device: &str, fstype: &str) -> Result<()> {
let device_c = CString::new(device).context("device contains a NUL")?;
let fstype_c = CString::new(fstype).context("fstype contains a NUL")?;
let f = symbol!(
self.lib,
b"krun_set_root_disk_remount\0",
unsafe extern "C" fn(u32, *const c_char, *const c_char, *const c_char) -> i32,
"the engine was built without the blk feature, so it ships no block devices"
);
// Safety: the CStrings are NUL-terminated and outlive the call.
krun_ret(
unsafe {
f(
ctx_id,
device_c.as_ptr(),
fstype_c.as_ptr(),
std::ptr::null(),
)
},
"krun_set_root_disk_remount",
)
}
/// `krun_add_virtiofs`: an extra tagged share, mounted guest-side.
pub fn add_virtiofs(&self, ctx_id: u32, tag: &str, path: &Path) -> Result<()> {
let tag_c = CString::new(tag).context("share tag contains a NUL")?;
+7 -6
View File
@@ -2,11 +2,11 @@
//!
//! This crate is the library behind the `ecrou` CLI. It resolves distro
//! and OCI image references, downloads them through a content cache,
//! extracts the rootfs into a scratch directory, and boots it as a
//! one-shot microVM: one command runs as the machine's only workload,
//! its stdio streams through, its exit code comes back, and the machine
//! is thrown away. The rootfs crosses as a virtiofs share, the workspace
//! as explicit shares the consumer names.
//! builds the ext4 image the machine boots, and runs it as a one-shot
//! microVM: one command runs as the machine's only workload, its stdio
//! streams through, its exit code comes back, and the machine is thrown
//! away. The rootfs crosses as a raw image on a virtio-blk disk, the
//! workspace as explicit virtiofs shares the consumer names.
//!
//! The founding rule: the host process never mounts a filesystem and
//! never enters a namespace. Every boundary crossing is a file or a
@@ -35,8 +35,9 @@ pub mod cache;
pub mod distro;
pub mod download;
pub mod error;
pub mod extract;
mod extract;
pub mod guest;
mod image;
pub mod krun;
pub mod progress;
mod registry;
+4 -4
View File
@@ -1,8 +1,8 @@
//! Progress reporting for long-running operations.
//!
//! The library never touches the terminal: downloads, extraction and
//! initramfs creation report their progress through the [`Progress`]
//! sink supplied by the caller. Consumers choose what to render —
//! The library never touches the terminal: downloads and image builds
//! report their progress through the [`Progress`] sink supplied by the
//! caller. Consumers choose what to render —
//! progress bars, spinners, structured events — or pass [`NoProgress`]
//! for silence.
@@ -11,7 +11,7 @@
pub enum Unit {
/// Bytes transferred (downloads).
Bytes,
/// Files processed (extraction, archive creation).
/// Files processed (archive merges, the image build).
Files,
}
+2 -1
View File
@@ -523,7 +523,8 @@ fn download_blob_with_auth(
}
/// Map an OCI layer mediaType to the appropriate file extension.
/// The extension is stored in layers.manifest and used by extract_oci_layer
/// The extension is stored in layers.manifest and read back by the
/// OCI merge in [`crate::extract`]
/// for compression dispatch, so it must accurately reflect the blob encoding.
fn media_type_to_extension(media_type: &str) -> &'static str {
match media_type {
+134 -27
View File
@@ -1,6 +1,12 @@
//! Cache-aware rootfs lifecycle: resolve an image reference, download
//! through the tarball cache, and extract into a scratch directory for
//! [`crate::vm`] to boot.
//! through the tarball cache, and build the ext4 image the machine
//! boots. A run copies the cached image into a scratch directory,
//! provisions its init into the copy, and throws it all away on exit.
//! The image is the deliverable: mke2fs builds it straight from the
//! tarball, so the ownership the archive records is the ownership the
//! guest sees - guest root on a real filesystem can then change it at
//! will, which a virtiofs share owned by the host user never allowed.
//!
//! The run path is [`RootfsCache::prepare`]: a present cache entry is
//! trusted as-is — the registry is never contacted — and the freshness
@@ -21,7 +27,7 @@ use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context};
use crate::cache::{extract_prepared, oci_freshness_changed, EntryLock, RootfsCache};
use crate::cache::{oci_freshness_changed, EntryLock, RootfsCache};
use crate::distro::{resolve_source, ImageSource};
use crate::download::download_image;
use crate::error::Error;
@@ -40,10 +46,10 @@ pub struct Pulled {
}
impl RootfsCache {
/// Resolve `req`, download through the cache when needed, and extract
/// into a fresh scratch directory (removed when the returned
/// [`PreparedRootfs`] is dropped). Download and extraction progress is
/// reported through `progress`.
/// Resolve `req`, download through the cache when needed, and copy
/// the entry image into a fresh scratch directory (removed when the
/// returned [`PreparedRootfs`] is dropped). Download and image-build
/// progress is reported through `progress`.
///
/// This is the boot path, not the freshness path: a present entry
/// is trusted as-is and the registry is never contacted — the
@@ -51,29 +57,72 @@ impl RootfsCache {
/// pulls by default, because there is nothing to boot otherwise.
pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result<PreparedRootfs> {
let (source, cache_path, _) = self.ensure_entry(req, progress, false)?;
// A local archive bypasses the cache: the file is the source
// on every run (--no-cache has nothing to say about it), and
// the derived entry name only matters for diagnostics.
if let ImageSource::LocalTarball { path } = &source {
return extract_prepared(path, progress).map_err(Error::Other);
let scratch = tempfile::tempdir().context("Failed to create temporary directory")?;
let image = scratch.path().join("rootfs.img");
match &source {
// A local archive bypasses the cache: the file is the
// source on every run (--no-cache has nothing to say about
// it), and the derived entry name only matters for
// diagnostics.
ImageSource::LocalTarball { path } => {
crate::image::build_rootfs_image(path, &image, progress)?;
}
_ => {
let entry_image = cache_path.with_extension("img");
self.ensure_image(&cache_path, &entry_image, progress)?;
crate::image::copy_image(&entry_image, &image)?;
}
}
Ok(extract_prepared(&cache_path, progress)?)
Ok(PreparedRootfs { scratch })
}
/// Fetch or refresh the image's cache entry without extracting it:
/// resolve the reference, re-check unpinned OCI tags against the
/// Fetch or refresh the cache entry without booting it: resolve
/// the reference, re-check unpinned OCI tags against the
/// registry digest, re-resolve floating distro aliases, and
/// download only when the entry is absent or has moved. The
/// explicit freshness operation — `prepare` (and so `run`) boots
/// the local entry untouched.
pub fn pull(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result<Pulled> {
let (_, cache_path, downloaded) = self.ensure_entry(req, progress, true)?;
// The image is the deliverable: pull leaves the entry ready to
// boot, so a run pays for a copy, never for a build.
let entry_image = cache_path.with_extension("img");
self.ensure_image(&cache_path, &entry_image, progress)?;
Ok(Pulled {
cache_path,
downloaded,
})
}
/// The entry's image, built from the entry tarball when absent or
/// older than the tarball. Runs copy the image; the tarball is the
/// build input only. One writer per entry, like the download path.
fn ensure_image(&self, tarball: &Path, image: &Path, progress: &dyn Progress) -> Result<()> {
let mtime = |p: &Path| {
std::fs::metadata(p)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
};
let fresh = || image.exists() && mtime(image) >= mtime(tarball);
if fresh() {
return Ok(());
}
std::fs::create_dir_all(&self.dir)
.with_context(|| format!("Failed to create cache directory {}", self.dir.display()))?;
let _entry_lock = EntryLock::acquire(image, progress).map_err(Error::Other)?;
// Re-derive freshness under the lock: a competing run may have
// built the image while we waited.
if fresh() {
log::debug!(
"another run built {} while we waited; using it",
image.display()
);
return Ok(());
}
crate::image::build_rootfs_image(tarball, image, progress)?;
Ok(())
}
/// The shared spine of `prepare` and `pull`: resolve the request
/// and see to it the pristine cache entry exists and is current.
///
@@ -190,18 +239,32 @@ impl PrepareRequest {
}
}
/// A rootfs extracted into a scratch directory, ready for
/// [`crate::vm::run`] to boot. The directory is removed when this
/// value is dropped.
/// A rootfs image copied into a scratch directory, ready for
/// [`crate::vm::run`] to boot. The scratch directory is removed when
/// this value is dropped.
#[derive(Debug)]
pub struct PreparedRootfs {
pub(crate) rootfs: TempDir,
pub(crate) scratch: TempDir,
}
impl PreparedRootfs {
/// The extracted rootfs directory.
/// The run's scratch directory: the image copy lives here, next to
/// the console capture and the files waiting to be provisioned in.
pub fn dir(&self) -> &Path {
self.rootfs.path()
self.scratch.path()
}
/// The image copy the machine boots.
pub(crate) fn image(&self) -> PathBuf {
self.scratch.path().join("rootfs.img")
}
/// Per-run surgery on the image copy: the generated init at its
/// fixed guest path, the host resolver over whatever the image
/// shipped, a stale control file removed. debugfs does it; nothing
/// mounts.
pub(crate) fn provision(&self, unlinks: &[&str], files: &[(&Path, &str, u32)]) -> Result<()> {
crate::image::provision(&self.image(), unlinks, files).map_err(Error::Other)
}
}
@@ -210,6 +273,13 @@ mod tests {
use super::*;
use crate::progress::NoProgress;
fn have_e2fsprogs() -> bool {
std::process::Command::new("mkfs.ext4")
.arg("-V")
.output()
.is_ok()
}
#[test]
fn pull_rejects_local_archives() {
let cache = RootfsCache::new("/cache");
@@ -220,6 +290,10 @@ mod tests {
#[test]
fn pull_on_a_cached_entry_reports_no_download() {
if !have_e2fsprogs() {
eprintln!("SKIP: no e2fsprogs");
return;
}
let dir = tempfile::tempdir().unwrap();
let cache = RootfsCache::new(dir.path().to_path_buf());
let pinned = crate::distro::parse_image_ref(
@@ -229,7 +303,18 @@ mod tests {
.unwrap();
let entry = cache.tarball_path(&pinned, "amd64");
std::fs::create_dir_all(entry.parent().unwrap()).unwrap();
std::fs::write(&entry, b"x").unwrap();
// A real (tiny) rootfs tarball: the entry is trusted, but pull
// still owes the cache its bootable image, built from it.
let file = std::fs::File::create(&entry).unwrap();
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
let mut tar = tar::Builder::new(encoder);
let mut header = tar::Header::new_gnu();
header.set_size(0);
header.set_entry_type(tar::EntryType::Directory);
header.set_mode(0o755);
tar.append_data(&mut header, "etc", std::io::empty())
.unwrap();
tar.into_inner().unwrap().finish().unwrap();
let req = PrepareRequest::new(
"ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
@@ -237,6 +322,17 @@ mod tests {
let pulled = cache.pull(&req, &NoProgress).unwrap();
assert!(!pulled.downloaded);
assert_eq!(pulled.cache_path, entry);
// The image is the deliverable: pull leaves it next to the
// tarball, and a second pull rebuilds nothing.
let image = entry.with_extension("img");
assert!(image.exists());
let before = std::fs::metadata(&image).unwrap().modified().unwrap();
let pulled = cache.pull(&req, &NoProgress).unwrap();
assert!(!pulled.downloaded);
assert_eq!(
std::fs::metadata(&image).unwrap().modified().unwrap(),
before
);
}
#[test]
@@ -277,12 +373,16 @@ mod tests {
#[test]
fn a_local_archive_prepares_without_touching_the_cache() {
if !have_e2fsprogs() {
eprintln!("SKIP: no e2fsprogs");
return;
}
// A rootfs tree, packed next to itself as ./rootfs.tar.gz, the
// way a caller names a local archive.
let work = tempfile::tempdir().unwrap();
let tree = work.path().join("tree");
std::fs::create_dir_all(&tree).unwrap();
std::fs::write(tree.join("etc-marker"), b"local").unwrap();
std::fs::write(tree.join("etc-marker"), b"local\n").unwrap();
let tarball = work.path().join("rootfs.tar.gz");
let file = std::fs::File::create(&tarball).unwrap();
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
@@ -296,10 +396,17 @@ mod tests {
// process's working directory, not the tempdir.
let req = PrepareRequest::new(tarball.to_str().unwrap());
let rootfs = cache.prepare(&req, &NoProgress).expect("prepares");
assert_eq!(
std::fs::read(rootfs.dir().join("etc-marker")).unwrap(),
b"local"
);
// The scratch holds a bootable image carrying the tree, not a
// tree itself: the marker is read back out of the image.
let image = rootfs.dir().join("rootfs.img");
assert!(image.exists());
let out = std::process::Command::new("debugfs")
.arg("-R")
.arg("cat /etc-marker")
.arg(&image)
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout), "local\n");
// Nothing was downloaded or stashed: the file is the source.
assert!(cache_dir.path().read_dir().unwrap().next().is_none());
}
+41 -35
View File
@@ -209,11 +209,11 @@ pub(crate) fn kill_machine(child: libc::pid_t) {
pub(crate) fn wait_machine(
child: libc::pid_t,
reaped: &std::sync::atomic::AtomicI32,
exit_file_host: &Path,
read_exit: &dyn Fn() -> Option<String>,
) -> Result<i32> {
let reaped_status = reaped.load(std::sync::atomic::Ordering::SeqCst);
if reaped_status >= 0 {
return decode_status(reaped_status, exit_file_host);
return decode_status(reaped_status, read_exit);
}
let mut status: libc::c_int = 0;
// Safety: waitpid on our own child; the pointer points at valid
@@ -233,17 +233,18 @@ pub(crate) fn wait_machine(
.context("waiting for the machine process"),
));
}
decode_status(status, exit_file_host)
decode_status(status, read_exit)
}
/// Translate the machine child's raw wait status into the result of a
/// run. Only the guest init can mint a guest exit code: it reports the
/// payload's code through the control file and the VMM exits the child
/// with it. Everything else is ecrou's own machinery talking, and is
/// an error, never a code — the shell's 128+n convention would
/// otherwise masquerade a SIGKILLed or OOM-killed VMM as the payload
/// having died by that signal.
fn decode_status(status: libc::c_int, exit_file_host: &Path) -> Result<i32> {
/// run. Only the guest init can mint a guest exit code, and it does so
/// through the control file, which outranks the child's own exit code:
/// with a block root the engine's exit-code channel (an ioctl through
/// the virtiofs root device) never fires, so the child's code is
/// machinery talk. Everything else is an error, never a code — the
/// shell's 128+n convention would otherwise masquerade a SIGKILLed or
/// OOM-killed VMM as the payload having died by that signal.
fn decode_status(status: libc::c_int, read_exit: &dyn Fn() -> Option<String>) -> Result<i32> {
use std::os::unix::process::ExitStatusExt;
let status = std::process::ExitStatus::from_raw(status);
if let Some(sig) = status.signal() {
@@ -256,18 +257,20 @@ fn decode_status(status: libc::c_int, exit_file_host: &Path) -> Result<i32> {
)));
}
let code = status.code().unwrap_or(125);
// The init writes the payload's code before it exits; a file that
// parses is the run's answer, whatever the child said.
if let Some(s) = read_exit() {
if let Ok(c) = s.trim().parse::<i32>() {
return Ok(c);
}
}
match code {
// Reserved by the machinery itself: 125 is the child's bail
// code (boot failure), 126 is the init refusing to run the
// payload (a share that would not mount, a missing workdir). A
// payload that genuinely exits either code reported through
// the control file first — the file is the arbiter.
// payload (a share that would not mount, a missing workdir).
// Nothing reported through the control file, so these are
// exactly what they say.
125 | 126 => {
if let Ok(s) = std::fs::read_to_string(exit_file_host) {
if let Ok(c) = s.trim().parse::<i32>() {
return Ok(c);
}
}
let cause = if code == 125 {
"the machine failed to boot"
} else {
@@ -304,7 +307,10 @@ mod tests {
signaled(libc::SIGKILL),
signaled(libc::SIGTERM),
] {
let err = decode_status(raw, &dir.path().join("absent")).unwrap_err();
let err = decode_status(raw, &|| {
std::fs::read_to_string(dir.path().join("absent")).ok()
})
.unwrap_err();
assert!(
err.to_string().contains("engine or hypervisor failure"),
"{err}"
@@ -316,36 +322,36 @@ mod tests {
fn a_boot_failure_is_an_error_and_a_payload_code_is_not() {
let dir = tempfile::tempdir().unwrap();
let exit_file = dir.path().join(".ecrou-exit");
let read = || std::fs::read_to_string(&exit_file).ok();
// 125 with no report: the child's bail code.
let err = decode_status(exited(125), &exit_file).unwrap_err();
let err = decode_status(exited(125), &read).unwrap_err();
assert!(err.to_string().contains("failed to boot"), "{err}");
// 126 with no report: the init refused (share, workdir).
let err = decode_status(exited(126), &exit_file).unwrap_err();
let err = decode_status(exited(126), &read).unwrap_err();
assert!(err.to_string().contains("share failed to mount"), "{err}");
// A payload that genuinely exited a reserved code reported
// through the control file first.
std::fs::write(&exit_file, "7").unwrap();
assert_eq!(decode_status(exited(125), &exit_file).unwrap(), 7);
assert_eq!(decode_status(exited(125), &read).unwrap(), 7);
std::fs::write(&exit_file, "3").unwrap();
assert_eq!(decode_status(exited(126), &exit_file).unwrap(), 3);
assert_eq!(decode_status(exited(126), &read).unwrap(), 3);
}
#[test]
fn an_ordinary_code_needs_no_control_file() {
fn the_control_file_outranks_the_childs_code() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
decode_status(exited(42), &dir.path().join("absent")).unwrap(),
42
);
assert_eq!(
decode_status(exited(0), &dir.path().join("absent")).unwrap(),
0
);
// A garbage control file does not turn a normal code into an
// error either — the code came through the VMM's own exit.
let exit_file = dir.path().join(".ecrou-exit");
// The usual block-root run: the payload exited 42, the engine
// had no channel, the child says 0. The file wins.
std::fs::write(&exit_file, "42").unwrap();
let read = || std::fs::read_to_string(&exit_file).ok();
assert_eq!(decode_status(exited(0), &read).unwrap(), 42);
// A file that does not parse falls through to the child's code.
std::fs::write(&exit_file, "not a number").unwrap();
assert_eq!(decode_status(exited(42), &exit_file).unwrap(), 42);
assert_eq!(decode_status(exited(42), &read).unwrap(), 42);
// An absent file too.
assert_eq!(decode_status(exited(42), &|| None::<String>).unwrap(), 42);
assert_eq!(decode_status(exited(0), &|| None::<String>).unwrap(), 0);
}
#[test]
+58 -54
View File
@@ -8,7 +8,7 @@
//! the control file read back for the exit code. The VMM runs inside
//! this process, so a kill of the process is a kill of the machine (T4).
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use anyhow::{anyhow, Context};
@@ -158,18 +158,24 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
})
.transpose()
.map_err(Error::Other)?;
let share_pairs = collect_shares(rootfs.dir(), options, capture_share).map_err(Error::Other)?;
let state = rootfs.dir().to_path_buf();
let image = rootfs.image();
let share_pairs = collect_shares(&state, options, capture_share).map_err(Error::Other)?;
let shares = share_pairs
.iter()
.map(|(_, gs)| gs.clone())
.collect::<Vec<_>>();
let payload = payload_command(options, rootfs.dir());
let env = compose_env(rootfs.dir(), options, &hostname);
// The shell the image ships, probed in the image: with no
// extracted tree, detection is a debugfs stat, not a file check.
let shell = crate::image::detect_shell(&image).map_err(Error::Other)?;
let payload = payload_command(options, shell);
let env = compose_env(shell, options, &hostname);
// The guest init is generated for every run: it is the exit-code
// contract (it writes the control file), the share mounter, and the
// hostname applier.
let init_path = rootfs.dir().join("ecrou-init");
// hostname applier. It lands inside the image, at the fixed path
// the init blob execs, through debugfs.
let init_path = state.join("ecrou-init");
let capture = options.capture.as_ref().map(|_| guest::GuestCapture {
stdout: guest::CAPTURE_STDOUT.to_string(),
stderr: guest::CAPTURE_STDERR.to_string(),
@@ -192,14 +198,24 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
);
std::fs::write(&init_path, script)
.with_context(|| format!("writing {}", init_path.display()))?;
set_exec_bit(&init_path)?;
let exit_file_host = rootfs.dir().join(
guest::EXIT_FILE
.strip_prefix('/')
.expect("EXIT_FILE is absolute at the guest root"),
);
let _ = std::fs::remove_file(&exit_file_host);
// Per-run surgery on the image copy: the init at its fixed guest
// path, the host resolver over whatever the image shipped, and a
// stale control file dropped. The unlink set is defensive: a
// tarball can ship a /.ecrou-exit, and resolver-bearing images
// ship /etc/resolv.conf as a symlink, which must go before the
// regular file lands.
let mut unlinks = vec![guest::EXIT_FILE];
let mut provisioned: Vec<(&std::path::Path, &str, u32)> =
vec![(&init_path, "/ecrou-init", 0o755)];
let host_resolv = state.join("resolv.conf");
if let Some(resolv) = crate::cache::host_resolver_content() {
std::fs::write(&host_resolv, resolv)
.with_context(|| format!("writing {}", host_resolv.display()))?;
unlinks.push("/etc/resolv.conf");
provisioned.push((&host_resolv, "/etc/resolv.conf", 0o644));
}
rootfs.provision(&unlinks, &provisioned)?;
krun.set_log_level(match options.log_level {
LogLevel::Quiet => 0, // off
@@ -208,7 +224,12 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
})?;
let ctx = krun.create_ctx()?;
krun.set_vm_config(ctx, vcpus, ram_mib)?;
krun.set_root(ctx, rootfs.dir())?;
// The rootfs crosses as a virtio-blk disk: the init blob mounts
// /dev/vda at /newroot, chroots, and execs the payload path from
// there. No virtiofs root, no set_root; the workspace and any
// named shares ride virtiofs as before.
krun.add_disk(ctx, "root", &image, false)?;
krun.set_root_disk_remount(ctx, "/dev/vda", "ext4")?;
for (share, gs) in &share_pairs {
krun.add_virtiofs(ctx, &gs.tag, &share.host)?;
}
@@ -237,7 +258,7 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
// process's own stdin and stdout (raw mode and its restore are
// libkrun's on a tty; pipe streams surface as virtio ports the
// init wires). A killed parent kills the machine through PDEATHSIG.
let console_path = rootfs.dir().join(".ecrou-console");
let console_path = state.join(".ecrou-console");
if !interactive {
std::fs::File::create(&console_path)
.with_context(|| format!("creating {}", console_path.display()))?;
@@ -256,16 +277,18 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
// normal payload exit ends the child through the VMM's own exit).
let child = crate::supervise::spawn_machine(&krun, ctx, &console_path, interactive)?;
// The control file lives inside the image now; the arbitration
// read is a debugfs cat after the machine has exited.
let read_exit = {
let image = image.clone();
move || crate::image::read_file(&image, guest::EXIT_FILE).ok()
};
let result = if interactive {
crate::supervise::wait_machine(
child,
&std::sync::atomic::AtomicI32::new(-1),
&exit_file_host,
)
crate::supervise::wait_machine(child, &std::sync::atomic::AtomicI32::new(-1), &read_exit)
} else {
let reaped = std::sync::atomic::AtomicI32::new(-1);
crate::supervise::stream_console(&console_path, child, &reaped)
.and_then(|()| crate::supervise::wait_machine(child, &reaped, &exit_file_host))
.and_then(|()| crate::supervise::wait_machine(child, &reaped, &read_exit))
};
// A parent-side failure (a console stat or read that went wrong, a
// wait that could not complete) must not leave the machine booting
@@ -361,8 +384,8 @@ fn reject_reserved_namespace(target: &str) -> anyhow::Result<()> {
}
/// The guest payload: the caller's argv, or the rootfs's default shell.
fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec<String> {
let shell = utils::detect_shell(rootfs_dir).to_string();
fn payload_command(options: &RunOptions, shell: &str) -> Vec<String> {
let shell = shell.to_string();
match options.command.as_slice() {
// No command: the image's shell.
[] => vec![shell],
@@ -380,15 +403,12 @@ fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec<String> {
/// The guest environment, composed from scratch: a usable base, the
/// workspace mount point, and the caller's pairs. Never the host
/// environment.
fn compose_env(rootfs_dir: &Path, options: &RunOptions, hostname: &str) -> Vec<(String, String)> {
fn compose_env(shell: &str, options: &RunOptions, hostname: &str) -> Vec<(String, String)> {
let host_term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string());
let mut env = vec![
("HOME".to_string(), "/root".to_string()),
("USER".to_string(), "root".to_string()),
(
"SHELL".to_string(),
utils::detect_shell(rootfs_dir).to_string(),
),
("SHELL".to_string(), shell.to_string()),
("TERM".to_string(), host_term),
("HOSTNAME".to_string(), hostname.to_string()),
(
@@ -405,15 +425,6 @@ fn compose_env(rootfs_dir: &Path, options: &RunOptions, hostname: &str) -> Vec<(
env
}
fn set_exec_bit(path: &Path) -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)
.with_context(|| format!("stat {}", path.display()))?
.permissions();
perms.set_mode(perms.mode() | 0o755);
std::fs::set_permissions(path, perms).with_context(|| format!("chmod +x {}", path.display()))
}
/// libkrun wants a vcpu count; all logical cpus is the sensible default
/// for a one-shot machine.
fn vcpus() -> u8 {
@@ -456,14 +467,6 @@ fn parse_memory_mib(s: &str) -> anyhow::Result<u32> {
mod tests {
use super::*;
/// A scratch with just a shell, for payload_command tests.
fn shell_rootfs() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("bin")).unwrap();
std::fs::write(dir.path().join("bin/sh"), "").unwrap();
dir
}
#[test]
fn caller_shares_cannot_take_the_reserved_namespace() {
let scratch = tempfile::tempdir().unwrap();
@@ -529,23 +532,26 @@ mod tests {
#[test]
fn payload_defaults_to_the_image_shell() {
let rootfs = shell_rootfs();
let options = RunOptions::default();
assert_eq!(
payload_command(&options, rootfs.path()),
payload_command(&options, "/bin/sh"),
vec!["/bin/sh".to_string()]
);
// Whatever the probe found stands in verbatim.
assert_eq!(
payload_command(&options, "/bin/bash"),
vec!["/bin/bash".to_string()]
);
}
#[test]
fn payload_argv_is_taken_literally() {
let rootfs = shell_rootfs();
let options = RunOptions {
command: vec!["uname".to_string(), "-a".to_string()],
..RunOptions::default()
};
assert_eq!(
payload_command(&options, rootfs.path()),
payload_command(&options, "/bin/sh"),
vec!["uname".to_string(), "-a".to_string()]
);
// Even when it looks like a shell invocation: the consumer
@@ -555,20 +561,19 @@ mod tests {
..RunOptions::default()
};
assert_eq!(
payload_command(&options, rootfs.path()),
payload_command(&options, "/bin/sh"),
vec!["sh".to_string(), "-c".to_string(), "exit 7".to_string()]
);
}
#[test]
fn payload_single_quoted_line_runs_through_the_shell() {
let rootfs = shell_rootfs();
let options = RunOptions {
command: vec!["apk add fastfetch && fastfetch".to_string()],
..RunOptions::default()
};
assert_eq!(
payload_command(&options, rootfs.path()),
payload_command(&options, "/bin/sh"),
vec![
"/bin/sh".to_string(),
"-c".to_string(),
@@ -610,12 +615,11 @@ mod tests {
fn compose_env_never_inherits_the_host() {
// compose_env reads the host TERM and nothing else from the
// process environment.
let dir = std::env::temp_dir();
let mut options = RunOptions::default();
options
.env
.push(("ECROU_TEST_MARK".to_string(), "present".to_string()));
let env = compose_env(&dir, &options, "ecrou-vm-x");
let env = compose_env("/bin/sh", &options, "ecrou-vm-x");
assert!(env
.iter()
.any(|(k, v)| k == "ECROU_TEST_MARK" && v == "present"));