mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
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:
1 parent
8cc3ba9854
commit
51f56116a3
11 files changed
+1035
-960
No files matched your search
@@ -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
@@ -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"));
|
||||
|
||||
+298
-770
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
}
|
||||
Ok(extract_prepared(&cache_path, 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(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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
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.
|
||||
125 | 126 => {
|
||||
if let Ok(s) = std::fs::read_to_string(exit_file_host) {
|
||||
// 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).
|
||||
// Nothing reported through the control file, so these are
|
||||
// exactly what they say.
|
||||
125 | 126 => {
|
||||
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
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user