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`)
|
(`crates/ecrou/src/rootfs.rs`)
|
||||||
- `cache` - the rootfs entry store: naming, locking, freshness,
|
- `cache` - the rootfs entry store: naming, locking, freshness,
|
||||||
layer pointers (`crates/ecrou/src/cache.rs`)
|
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`)
|
- `distro` - distro definitions and mirrors (`crates/ecrou/src/distro.rs`)
|
||||||
- `download` - image download (`crates/ecrou/src/download.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`)
|
- `registry` - OCI registry client (`crates/ecrou/src/registry.rs`)
|
||||||
- `utils`, `error`, `progress` - shared helpers
|
- `utils`, `error`, `progress` - shared helpers
|
||||||
- `deps` - dependency additions/bumps (manifests, lockfile)
|
- `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::distro::{map_arch, Distro, ImageSource};
|
||||||
use crate::download::digest_sidecar;
|
use crate::download::digest_sidecar;
|
||||||
use crate::extract::extract_tarball;
|
|
||||||
use crate::progress::Progress;
|
use crate::progress::Progress;
|
||||||
use crate::registry::fetch_oci_digest;
|
use crate::registry::fetch_oci_digest;
|
||||||
use crate::rootfs::PreparedRootfs;
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Whether an OCI image with a floating tag (":latest") has moved since
|
/// Whether an OCI image with a floating tag (":latest") has moved since
|
||||||
@@ -135,66 +133,37 @@ impl RootfsCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn extract_prepared(
|
/// The host resolver config, ready to provision into the machine: the
|
||||||
source: &Path,
|
/// engine proxies guest sockets to the host's stack, so a nameserver
|
||||||
progress: &dyn Progress,
|
/// line that works on the host works in the machine, loopback stub
|
||||||
) -> anyhow::Result<PreparedRootfs> {
|
/// included. Without it the guest has whatever the image shipped,
|
||||||
let temp = tempfile::tempdir().context("Failed to create temporary directory")?;
|
/// which is usually nothing. Best effort: None leaves the image's own
|
||||||
let rootfs = temp.path().to_path_buf();
|
/// file standing.
|
||||||
|
pub(crate) fn host_resolver_content() -> Option<String> {
|
||||||
log::debug!("Extracting to: {}", rootfs.display());
|
// Loopback addresses do not cross: 127.0.0.53 stays inside the
|
||||||
extract_tarball(source, &rootfs, progress)?;
|
// guest, where nothing listens. The source of truth is
|
||||||
provision_resolver(&rootfs);
|
// systemd-resolved's uplink config when present, and loopback
|
||||||
|
// nameservers are dropped from whatever is copied.
|
||||||
Ok(PreparedRootfs { rootfs: temp })
|
match std::fs::read_to_string("/run/systemd/resolve/resolv.conf")
|
||||||
}
|
|
||||||
|
|
||||||
/// 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")
|
|
||||||
.or_else(|_| std::fs::read_to_string("/etc/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(_) => {
|
Ok(_) => {
|
||||||
log::debug!("host resolver config is empty; leaving the image's in place");
|
log::debug!("host resolver config is empty; leaving the image's in place");
|
||||||
return;
|
None
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::debug!(
|
log::debug!("no host resolver config ({e}); leaving the image's in place");
|
||||||
"no host resolver config ({}); leaving the image's in place",
|
None
|
||||||
e
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
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]
|
#[test]
|
||||||
fn a_digest_pin_names_its_own_cache_entry() {
|
fn a_digest_pin_names_its_own_cache_entry() {
|
||||||
let cache = RootfsCache::new(std::path::PathBuf::from("/cache"));
|
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)";
|
at the directory holding it (a snap stages it next to the binary)";
|
||||||
|
|
||||||
macro_rules! symbol {
|
macro_rules! symbol {
|
||||||
($lib:expr, $name:expr, $ty:ty) => {{
|
($lib:expr, $name:expr, $ty:ty) => {
|
||||||
|
symbol!($lib, $name, $ty, "libkrun too old?")
|
||||||
|
};
|
||||||
|
($lib:expr, $name:expr, $ty:ty, $hint:expr) => {{
|
||||||
// Safety: the symbol is used through the library handle with the
|
// Safety: the symbol is used through the library handle with the
|
||||||
// signature from libkrun.h written at the call site.
|
// signature from libkrun.h written at the call site.
|
||||||
unsafe { $lib.get::<$ty>($name) }.with_context(|| {
|
unsafe { $lib.get::<$ty>($name) }.with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"symbol {} missing: libkrun too old?",
|
"symbol {} missing: {}",
|
||||||
String::from_utf8_lossy($name)
|
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_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.
|
/// `krun_add_virtiofs`: an extra tagged share, mounted guest-side.
|
||||||
pub fn add_virtiofs(&self, ctx_id: u32, tag: &str, path: &Path) -> Result<()> {
|
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")?;
|
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
|
//! This crate is the library behind the `ecrou` CLI. It resolves distro
|
||||||
//! and OCI image references, downloads them through a content cache,
|
//! and OCI image references, downloads them through a content cache,
|
||||||
//! extracts the rootfs into a scratch directory, and boots it as a
|
//! builds the ext4 image the machine boots, and runs it as a one-shot
|
||||||
//! one-shot microVM: one command runs as the machine's only workload,
|
//! microVM: one command runs as the machine's only workload, its stdio
|
||||||
//! its stdio streams through, its exit code comes back, and the machine
|
//! streams through, its exit code comes back, and the machine is thrown
|
||||||
//! is thrown away. The rootfs crosses as a virtiofs share, the workspace
|
//! away. The rootfs crosses as a raw image on a virtio-blk disk, the
|
||||||
//! as explicit shares the consumer names.
|
//! workspace as explicit virtiofs shares the consumer names.
|
||||||
//!
|
//!
|
||||||
//! The founding rule: the host process never mounts a filesystem and
|
//! The founding rule: the host process never mounts a filesystem and
|
||||||
//! never enters a namespace. Every boundary crossing is a file or a
|
//! never enters a namespace. Every boundary crossing is a file or a
|
||||||
@@ -35,8 +35,9 @@ pub mod cache;
|
|||||||
pub mod distro;
|
pub mod distro;
|
||||||
pub mod download;
|
pub mod download;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod extract;
|
mod extract;
|
||||||
pub mod guest;
|
pub mod guest;
|
||||||
|
mod image;
|
||||||
pub mod krun;
|
pub mod krun;
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
mod registry;
|
mod registry;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Progress reporting for long-running operations.
|
//! Progress reporting for long-running operations.
|
||||||
//!
|
//!
|
||||||
//! The library never touches the terminal: downloads, extraction and
|
//! The library never touches the terminal: downloads and image builds
|
||||||
//! initramfs creation report their progress through the [`Progress`]
|
//! report their progress through the [`Progress`] sink supplied by the
|
||||||
//! sink supplied by the caller. Consumers choose what to render —
|
//! caller. Consumers choose what to render —
|
||||||
//! progress bars, spinners, structured events — or pass [`NoProgress`]
|
//! progress bars, spinners, structured events — or pass [`NoProgress`]
|
||||||
//! for silence.
|
//! for silence.
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
pub enum Unit {
|
pub enum Unit {
|
||||||
/// Bytes transferred (downloads).
|
/// Bytes transferred (downloads).
|
||||||
Bytes,
|
Bytes,
|
||||||
/// Files processed (extraction, archive creation).
|
/// Files processed (archive merges, the image build).
|
||||||
Files,
|
Files,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -523,7 +523,8 @@ fn download_blob_with_auth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Map an OCI layer mediaType to the appropriate file extension.
|
/// 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.
|
/// for compression dispatch, so it must accurately reflect the blob encoding.
|
||||||
fn media_type_to_extension(media_type: &str) -> &'static str {
|
fn media_type_to_extension(media_type: &str) -> &'static str {
|
||||||
match media_type {
|
match media_type {
|
||||||
|
|||||||
+134
-27
@@ -1,6 +1,12 @@
|
|||||||
//! Cache-aware rootfs lifecycle: resolve an image reference, download
|
//! Cache-aware rootfs lifecycle: resolve an image reference, download
|
||||||
//! through the tarball cache, and extract into a scratch directory for
|
//! through the tarball cache, and build the ext4 image the machine
|
||||||
//! [`crate::vm`] to boot.
|
//! 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
|
//! The run path is [`RootfsCache::prepare`]: a present cache entry is
|
||||||
//! trusted as-is — the registry is never contacted — and the freshness
|
//! 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 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::distro::{resolve_source, ImageSource};
|
||||||
use crate::download::download_image;
|
use crate::download::download_image;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
@@ -40,10 +46,10 @@ pub struct Pulled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RootfsCache {
|
impl RootfsCache {
|
||||||
/// Resolve `req`, download through the cache when needed, and extract
|
/// Resolve `req`, download through the cache when needed, and copy
|
||||||
/// into a fresh scratch directory (removed when the returned
|
/// the entry image into a fresh scratch directory (removed when the
|
||||||
/// [`PreparedRootfs`] is dropped). Download and extraction progress is
|
/// returned [`PreparedRootfs`] is dropped). Download and image-build
|
||||||
/// reported through `progress`.
|
/// progress is reported through `progress`.
|
||||||
///
|
///
|
||||||
/// This is the boot path, not the freshness path: a present entry
|
/// This is the boot path, not the freshness path: a present entry
|
||||||
/// is trusted as-is and the registry is never contacted — the
|
/// 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.
|
/// pulls by default, because there is nothing to boot otherwise.
|
||||||
pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result<PreparedRootfs> {
|
pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result<PreparedRootfs> {
|
||||||
let (source, cache_path, _) = self.ensure_entry(req, progress, false)?;
|
let (source, cache_path, _) = self.ensure_entry(req, progress, false)?;
|
||||||
// A local archive bypasses the cache: the file is the source
|
let scratch = tempfile::tempdir().context("Failed to create temporary directory")?;
|
||||||
// on every run (--no-cache has nothing to say about it), and
|
let image = scratch.path().join("rootfs.img");
|
||||||
// the derived entry name only matters for diagnostics.
|
match &source {
|
||||||
if let ImageSource::LocalTarball { path } = &source {
|
// A local archive bypasses the cache: the file is the
|
||||||
return extract_prepared(path, progress).map_err(Error::Other);
|
// 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:
|
/// Fetch or refresh the cache entry without booting it: resolve
|
||||||
/// resolve the reference, re-check unpinned OCI tags against the
|
/// the reference, re-check unpinned OCI tags against the
|
||||||
/// registry digest, re-resolve floating distro aliases, and
|
/// registry digest, re-resolve floating distro aliases, and
|
||||||
/// download only when the entry is absent or has moved. The
|
/// download only when the entry is absent or has moved. The
|
||||||
/// explicit freshness operation — `prepare` (and so `run`) boots
|
/// explicit freshness operation — `prepare` (and so `run`) boots
|
||||||
/// the local entry untouched.
|
/// the local entry untouched.
|
||||||
pub fn pull(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result<Pulled> {
|
pub fn pull(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result<Pulled> {
|
||||||
let (_, cache_path, downloaded) = self.ensure_entry(req, progress, true)?;
|
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 {
|
Ok(Pulled {
|
||||||
cache_path,
|
cache_path,
|
||||||
downloaded,
|
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
|
/// The shared spine of `prepare` and `pull`: resolve the request
|
||||||
/// and see to it the pristine cache entry exists and is current.
|
/// 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
|
/// A rootfs image copied into a scratch directory, ready for
|
||||||
/// [`crate::vm::run`] to boot. The directory is removed when this
|
/// [`crate::vm::run`] to boot. The scratch directory is removed when
|
||||||
/// value is dropped.
|
/// this value is dropped.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PreparedRootfs {
|
pub struct PreparedRootfs {
|
||||||
pub(crate) rootfs: TempDir,
|
pub(crate) scratch: TempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PreparedRootfs {
|
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 {
|
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 super::*;
|
||||||
use crate::progress::NoProgress;
|
use crate::progress::NoProgress;
|
||||||
|
|
||||||
|
fn have_e2fsprogs() -> bool {
|
||||||
|
std::process::Command::new("mkfs.ext4")
|
||||||
|
.arg("-V")
|
||||||
|
.output()
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pull_rejects_local_archives() {
|
fn pull_rejects_local_archives() {
|
||||||
let cache = RootfsCache::new("/cache");
|
let cache = RootfsCache::new("/cache");
|
||||||
@@ -220,6 +290,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pull_on_a_cached_entry_reports_no_download() {
|
fn pull_on_a_cached_entry_reports_no_download() {
|
||||||
|
if !have_e2fsprogs() {
|
||||||
|
eprintln!("SKIP: no e2fsprogs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let cache = RootfsCache::new(dir.path().to_path_buf());
|
let cache = RootfsCache::new(dir.path().to_path_buf());
|
||||||
let pinned = crate::distro::parse_image_ref(
|
let pinned = crate::distro::parse_image_ref(
|
||||||
@@ -229,7 +303,18 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let entry = cache.tarball_path(&pinned, "amd64");
|
let entry = cache.tarball_path(&pinned, "amd64");
|
||||||
std::fs::create_dir_all(entry.parent().unwrap()).unwrap();
|
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(
|
let req = PrepareRequest::new(
|
||||||
"ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
"ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
@@ -237,6 +322,17 @@ mod tests {
|
|||||||
let pulled = cache.pull(&req, &NoProgress).unwrap();
|
let pulled = cache.pull(&req, &NoProgress).unwrap();
|
||||||
assert!(!pulled.downloaded);
|
assert!(!pulled.downloaded);
|
||||||
assert_eq!(pulled.cache_path, entry);
|
assert_eq!(pulled.cache_path, entry);
|
||||||
|
// 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]
|
#[test]
|
||||||
@@ -277,12 +373,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_local_archive_prepares_without_touching_the_cache() {
|
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
|
// A rootfs tree, packed next to itself as ./rootfs.tar.gz, the
|
||||||
// way a caller names a local archive.
|
// way a caller names a local archive.
|
||||||
let work = tempfile::tempdir().unwrap();
|
let work = tempfile::tempdir().unwrap();
|
||||||
let tree = work.path().join("tree");
|
let tree = work.path().join("tree");
|
||||||
std::fs::create_dir_all(&tree).unwrap();
|
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 tarball = work.path().join("rootfs.tar.gz");
|
||||||
let file = std::fs::File::create(&tarball).unwrap();
|
let file = std::fs::File::create(&tarball).unwrap();
|
||||||
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||||
@@ -296,10 +396,17 @@ mod tests {
|
|||||||
// process's working directory, not the tempdir.
|
// process's working directory, not the tempdir.
|
||||||
let req = PrepareRequest::new(tarball.to_str().unwrap());
|
let req = PrepareRequest::new(tarball.to_str().unwrap());
|
||||||
let rootfs = cache.prepare(&req, &NoProgress).expect("prepares");
|
let rootfs = cache.prepare(&req, &NoProgress).expect("prepares");
|
||||||
assert_eq!(
|
// The scratch holds a bootable image carrying the tree, not a
|
||||||
std::fs::read(rootfs.dir().join("etc-marker")).unwrap(),
|
// tree itself: the marker is read back out of the image.
|
||||||
b"local"
|
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.
|
// Nothing was downloaded or stashed: the file is the source.
|
||||||
assert!(cache_dir.path().read_dir().unwrap().next().is_none());
|
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(
|
pub(crate) fn wait_machine(
|
||||||
child: libc::pid_t,
|
child: libc::pid_t,
|
||||||
reaped: &std::sync::atomic::AtomicI32,
|
reaped: &std::sync::atomic::AtomicI32,
|
||||||
exit_file_host: &Path,
|
read_exit: &dyn Fn() -> Option<String>,
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
let reaped_status = reaped.load(std::sync::atomic::Ordering::SeqCst);
|
let reaped_status = reaped.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
if reaped_status >= 0 {
|
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;
|
let mut status: libc::c_int = 0;
|
||||||
// Safety: waitpid on our own child; the pointer points at valid
|
// 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"),
|
.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
|
/// 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
|
/// run. Only the guest init can mint a guest exit code, and it does so
|
||||||
/// payload's code through the control file and the VMM exits the child
|
/// through the control file, which outranks the child's own exit code:
|
||||||
/// with it. Everything else is ecrou's own machinery talking, and is
|
/// with a block root the engine's exit-code channel (an ioctl through
|
||||||
/// an error, never a code — the shell's 128+n convention would
|
/// the virtiofs root device) never fires, so the child's code is
|
||||||
/// otherwise masquerade a SIGKILLed or OOM-killed VMM as the payload
|
/// machinery talk. Everything else is an error, never a code — the
|
||||||
/// having died by that signal.
|
/// shell's 128+n convention would otherwise masquerade a SIGKILLed or
|
||||||
fn decode_status(status: libc::c_int, exit_file_host: &Path) -> Result<i32> {
|
/// 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;
|
use std::os::unix::process::ExitStatusExt;
|
||||||
let status = std::process::ExitStatus::from_raw(status);
|
let status = std::process::ExitStatus::from_raw(status);
|
||||||
if let Some(sig) = status.signal() {
|
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);
|
let code = status.code().unwrap_or(125);
|
||||||
match code {
|
// The init writes the payload's code before it exits; a file that
|
||||||
// Reserved by the machinery itself: 125 is the child's bail
|
// parses is the run's answer, whatever the child said.
|
||||||
// code (boot failure), 126 is the init refusing to run the
|
if let Some(s) = read_exit() {
|
||||||
// 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) {
|
|
||||||
if let Ok(c) = s.trim().parse::<i32>() {
|
if let Ok(c) = s.trim().parse::<i32>() {
|
||||||
return Ok(c);
|
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 {
|
let cause = if code == 125 {
|
||||||
"the machine failed to boot"
|
"the machine failed to boot"
|
||||||
} else {
|
} else {
|
||||||
@@ -304,7 +307,10 @@ mod tests {
|
|||||||
signaled(libc::SIGKILL),
|
signaled(libc::SIGKILL),
|
||||||
signaled(libc::SIGTERM),
|
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!(
|
assert!(
|
||||||
err.to_string().contains("engine or hypervisor failure"),
|
err.to_string().contains("engine or hypervisor failure"),
|
||||||
"{err}"
|
"{err}"
|
||||||
@@ -316,36 +322,36 @@ mod tests {
|
|||||||
fn a_boot_failure_is_an_error_and_a_payload_code_is_not() {
|
fn a_boot_failure_is_an_error_and_a_payload_code_is_not() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let exit_file = dir.path().join(".ecrou-exit");
|
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.
|
// 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}");
|
assert!(err.to_string().contains("failed to boot"), "{err}");
|
||||||
// 126 with no report: the init refused (share, workdir).
|
// 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}");
|
assert!(err.to_string().contains("share failed to mount"), "{err}");
|
||||||
// A payload that genuinely exited a reserved code reported
|
// A payload that genuinely exited a reserved code reported
|
||||||
// through the control file first.
|
// through the control file first.
|
||||||
std::fs::write(&exit_file, "7").unwrap();
|
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();
|
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]
|
#[test]
|
||||||
fn an_ordinary_code_needs_no_control_file() {
|
fn the_control_file_outranks_the_childs_code() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
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");
|
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();
|
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]
|
#[test]
|
||||||
|
|||||||
+58
-54
@@ -8,7 +8,7 @@
|
|||||||
//! the control file read back for the exit code. The VMM runs inside
|
//! the control file read back for the exit code. The VMM runs inside
|
||||||
//! this process, so a kill of the process is a kill of the machine (T4).
|
//! 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};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
@@ -158,18 +158,24 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
|
|||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(Error::Other)?;
|
.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
|
let shares = share_pairs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, gs)| gs.clone())
|
.map(|(_, gs)| gs.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let payload = payload_command(options, rootfs.dir());
|
// The shell the image ships, probed in the image: with no
|
||||||
let env = compose_env(rootfs.dir(), options, &hostname);
|
// 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
|
// The guest init is generated for every run: it is the exit-code
|
||||||
// contract (it writes the control file), the share mounter, and the
|
// contract (it writes the control file), the share mounter, and the
|
||||||
// hostname applier.
|
// hostname applier. It lands inside the image, at the fixed path
|
||||||
let init_path = rootfs.dir().join("ecrou-init");
|
// the init blob execs, through debugfs.
|
||||||
|
let init_path = state.join("ecrou-init");
|
||||||
let capture = options.capture.as_ref().map(|_| guest::GuestCapture {
|
let capture = options.capture.as_ref().map(|_| guest::GuestCapture {
|
||||||
stdout: guest::CAPTURE_STDOUT.to_string(),
|
stdout: guest::CAPTURE_STDOUT.to_string(),
|
||||||
stderr: guest::CAPTURE_STDERR.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)
|
std::fs::write(&init_path, script)
|
||||||
.with_context(|| format!("writing {}", init_path.display()))?;
|
.with_context(|| format!("writing {}", init_path.display()))?;
|
||||||
set_exec_bit(&init_path)?;
|
|
||||||
|
|
||||||
let exit_file_host = rootfs.dir().join(
|
// Per-run surgery on the image copy: the init at its fixed guest
|
||||||
guest::EXIT_FILE
|
// path, the host resolver over whatever the image shipped, and a
|
||||||
.strip_prefix('/')
|
// stale control file dropped. The unlink set is defensive: a
|
||||||
.expect("EXIT_FILE is absolute at the guest root"),
|
// tarball can ship a /.ecrou-exit, and resolver-bearing images
|
||||||
);
|
// ship /etc/resolv.conf as a symlink, which must go before the
|
||||||
let _ = std::fs::remove_file(&exit_file_host);
|
// 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 {
|
krun.set_log_level(match options.log_level {
|
||||||
LogLevel::Quiet => 0, // off
|
LogLevel::Quiet => 0, // off
|
||||||
@@ -208,7 +224,12 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
|
|||||||
})?;
|
})?;
|
||||||
let ctx = krun.create_ctx()?;
|
let ctx = krun.create_ctx()?;
|
||||||
krun.set_vm_config(ctx, vcpus, ram_mib)?;
|
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 {
|
for (share, gs) in &share_pairs {
|
||||||
krun.add_virtiofs(ctx, &gs.tag, &share.host)?;
|
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
|
// process's own stdin and stdout (raw mode and its restore are
|
||||||
// libkrun's on a tty; pipe streams surface as virtio ports the
|
// libkrun's on a tty; pipe streams surface as virtio ports the
|
||||||
// init wires). A killed parent kills the machine through PDEATHSIG.
|
// 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 {
|
if !interactive {
|
||||||
std::fs::File::create(&console_path)
|
std::fs::File::create(&console_path)
|
||||||
.with_context(|| format!("creating {}", console_path.display()))?;
|
.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).
|
// normal payload exit ends the child through the VMM's own exit).
|
||||||
let child = crate::supervise::spawn_machine(&krun, ctx, &console_path, interactive)?;
|
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 {
|
let result = if interactive {
|
||||||
crate::supervise::wait_machine(
|
crate::supervise::wait_machine(child, &std::sync::atomic::AtomicI32::new(-1), &read_exit)
|
||||||
child,
|
|
||||||
&std::sync::atomic::AtomicI32::new(-1),
|
|
||||||
&exit_file_host,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
let reaped = std::sync::atomic::AtomicI32::new(-1);
|
let reaped = std::sync::atomic::AtomicI32::new(-1);
|
||||||
crate::supervise::stream_console(&console_path, child, &reaped)
|
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
|
// A parent-side failure (a console stat or read that went wrong, a
|
||||||
// wait that could not complete) must not leave the machine booting
|
// 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.
|
/// The guest payload: the caller's argv, or the rootfs's default shell.
|
||||||
fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec<String> {
|
fn payload_command(options: &RunOptions, shell: &str) -> Vec<String> {
|
||||||
let shell = utils::detect_shell(rootfs_dir).to_string();
|
let shell = shell.to_string();
|
||||||
match options.command.as_slice() {
|
match options.command.as_slice() {
|
||||||
// No command: the image's shell.
|
// No command: the image's shell.
|
||||||
[] => vec![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
|
/// The guest environment, composed from scratch: a usable base, the
|
||||||
/// workspace mount point, and the caller's pairs. Never the host
|
/// workspace mount point, and the caller's pairs. Never the host
|
||||||
/// environment.
|
/// 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 host_term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string());
|
||||||
let mut env = vec![
|
let mut env = vec![
|
||||||
("HOME".to_string(), "/root".to_string()),
|
("HOME".to_string(), "/root".to_string()),
|
||||||
("USER".to_string(), "root".to_string()),
|
("USER".to_string(), "root".to_string()),
|
||||||
(
|
("SHELL".to_string(), shell.to_string()),
|
||||||
"SHELL".to_string(),
|
|
||||||
utils::detect_shell(rootfs_dir).to_string(),
|
|
||||||
),
|
|
||||||
("TERM".to_string(), host_term),
|
("TERM".to_string(), host_term),
|
||||||
("HOSTNAME".to_string(), hostname.to_string()),
|
("HOSTNAME".to_string(), hostname.to_string()),
|
||||||
(
|
(
|
||||||
@@ -405,15 +425,6 @@ fn compose_env(rootfs_dir: &Path, options: &RunOptions, hostname: &str) -> Vec<(
|
|||||||
env
|
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
|
/// libkrun wants a vcpu count; all logical cpus is the sensible default
|
||||||
/// for a one-shot machine.
|
/// for a one-shot machine.
|
||||||
fn vcpus() -> u8 {
|
fn vcpus() -> u8 {
|
||||||
@@ -456,14 +467,6 @@ fn parse_memory_mib(s: &str) -> anyhow::Result<u32> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn caller_shares_cannot_take_the_reserved_namespace() {
|
fn caller_shares_cannot_take_the_reserved_namespace() {
|
||||||
let scratch = tempfile::tempdir().unwrap();
|
let scratch = tempfile::tempdir().unwrap();
|
||||||
@@ -529,23 +532,26 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn payload_defaults_to_the_image_shell() {
|
fn payload_defaults_to_the_image_shell() {
|
||||||
let rootfs = shell_rootfs();
|
|
||||||
let options = RunOptions::default();
|
let options = RunOptions::default();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload_command(&options, rootfs.path()),
|
payload_command(&options, "/bin/sh"),
|
||||||
vec!["/bin/sh".to_string()]
|
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]
|
#[test]
|
||||||
fn payload_argv_is_taken_literally() {
|
fn payload_argv_is_taken_literally() {
|
||||||
let rootfs = shell_rootfs();
|
|
||||||
let options = RunOptions {
|
let options = RunOptions {
|
||||||
command: vec!["uname".to_string(), "-a".to_string()],
|
command: vec!["uname".to_string(), "-a".to_string()],
|
||||||
..RunOptions::default()
|
..RunOptions::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload_command(&options, rootfs.path()),
|
payload_command(&options, "/bin/sh"),
|
||||||
vec!["uname".to_string(), "-a".to_string()]
|
vec!["uname".to_string(), "-a".to_string()]
|
||||||
);
|
);
|
||||||
// Even when it looks like a shell invocation: the consumer
|
// Even when it looks like a shell invocation: the consumer
|
||||||
@@ -555,20 +561,19 @@ mod tests {
|
|||||||
..RunOptions::default()
|
..RunOptions::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload_command(&options, rootfs.path()),
|
payload_command(&options, "/bin/sh"),
|
||||||
vec!["sh".to_string(), "-c".to_string(), "exit 7".to_string()]
|
vec!["sh".to_string(), "-c".to_string(), "exit 7".to_string()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn payload_single_quoted_line_runs_through_the_shell() {
|
fn payload_single_quoted_line_runs_through_the_shell() {
|
||||||
let rootfs = shell_rootfs();
|
|
||||||
let options = RunOptions {
|
let options = RunOptions {
|
||||||
command: vec!["apk add fastfetch && fastfetch".to_string()],
|
command: vec!["apk add fastfetch && fastfetch".to_string()],
|
||||||
..RunOptions::default()
|
..RunOptions::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload_command(&options, rootfs.path()),
|
payload_command(&options, "/bin/sh"),
|
||||||
vec![
|
vec![
|
||||||
"/bin/sh".to_string(),
|
"/bin/sh".to_string(),
|
||||||
"-c".to_string(),
|
"-c".to_string(),
|
||||||
@@ -610,12 +615,11 @@ mod tests {
|
|||||||
fn compose_env_never_inherits_the_host() {
|
fn compose_env_never_inherits_the_host() {
|
||||||
// compose_env reads the host TERM and nothing else from the
|
// compose_env reads the host TERM and nothing else from the
|
||||||
// process environment.
|
// process environment.
|
||||||
let dir = std::env::temp_dir();
|
|
||||||
let mut options = RunOptions::default();
|
let mut options = RunOptions::default();
|
||||||
options
|
options
|
||||||
.env
|
.env
|
||||||
.push(("ECROU_TEST_MARK".to_string(), "present".to_string()));
|
.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
|
assert!(env
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(k, v)| k == "ECROU_TEST_MARK" && v == "present"));
|
.any(|(k, v)| k == "ECROU_TEST_MARK" && v == "present"));
|
||||||
|
|||||||
Reference in new issue
Block a user