Files
boco/src/qemu.rs
T
marvin f2b68004ae
CI / build (pull_request) Successful in 12s
Support host:guest mount syntax with WORKINGDIR resolution
--mount now accepts <host-path>:<guest-path> (Docker-style). When the
guest path is omitted, the host path is used. Non-absolute guest paths
are resolved against the image WORKINGDIR, which is written to
/slim/workdir at build time and read by slim-init.sh at boot.

Changes:
- qemu.rs: Parse host:guest spec, pass guest path (not host path) on
  the kernel cmdline as slim.mount=<tag>:<base64(guest_path)>
- inject.rs: Accept working_dir param, write /slim/workdir into rootfs
- build.rs: Pass config.working_dir to inject()
- slim-init.sh: Read /slim/workdir, resolve relative guest paths
  against it before mounting
- test.sh: Test host:guest absolute paths (multi-mount) and relative
  guest path resolved against WORKINGDIR

Addresses PR #9 review comment from @hulthe.
2026-09-10 12:12:07 +02:00

164 lines
5.7 KiB
Rust

//! QEMU: boot a registry entry with qemu-system-x86_64 using direct kernel
//! boot (-kernel/-initrd or -drive). The kernel is shared across all VMs
//! at $XDG_DATA_HOME/slim-rs/registry/vmlinuz. A universal /slim/init script (injected
//! at build time) handles VM bootstrap; /slim/exec runs the container's
//! CMD/ENTRYPOINT. Runtime overrides are delivered as `slim.cmd=<base64>`
//! on the kernel cmdline.
use anyhow::{Context, Result, bail};
use base64::Engine;
use clap::Args;
use std::{fmt::Write as _, process::Command};
use crate::{
forward::PortForward,
registry::{registry_base_dir, registry_dir},
};
#[derive(Args, Debug)]
pub struct RunCmd {
/// Image tag / registry subdir name
name: String,
/// Amount of memory to give the VM, in qemu's format.
#[clap(short, long, default_value = "1024M")]
memory: String,
/// Forward ports from host to guest. Example: `tcp:0.0.0.0:80-:8080`
#[clap(long)]
forward: Vec<PortForward>,
/// Override the command to exec in the VM (base64-encoded on the kernel
/// cmdline as slim.cmd=<b64>). Overrides the CMD inferred at build time.
#[clap(long)]
cmd: Option<String>,
/// Share a host directory into the VM via 9p. Format:
/// `<host-path>` or `<host-path>:<guest-path>`. When the guest path is
/// omitted, the host path is used. Non-absolute guest paths are
/// relative to the image's WORKINGDIR. Can be repeated.
#[clap(long)]
mount: Vec<String>,
}
pub(crate) fn run(
RunCmd {
name,
memory,
forward,
cmd,
mount,
}: RunCmd,
) -> Result<()> {
let reg_dir = registry_dir(&name)?;
let vmlinuz_path = registry_base_dir()?.join("vmlinuz");
let initrd_path = reg_dir.join("initrd");
let qcow2_path = reg_dir.join("image.qcow2");
if !vmlinuz_path.exists() {
// TODO: guide user in how to set up a kernel
bail!(
"No kernel available. Place a vmlinuz at {}",
vmlinuz_path.display()
);
}
let mut cmdline: Vec<String> = vec![
"console=ttyS0,115200".into(),
"rw".into(),
"earlyprintk=serial".into(),
"nokaslr".into(),
"init=/slim/init".into(),
"devtmpfs.mount=1".into(), // Automatically mount /dev at boot
];
let fs_args;
let qcow2_arg = format!("file={},format=qcow2,if=virtio", qcow2_path.display());
if qcow2_path.exists() {
fs_args = vec!["-drive".to_string(), qcow2_arg];
cmdline.extend(["root=/dev/vda".to_string(), "rootfstype=ext4".to_string()]);
} else if initrd_path.exists() {
let initrd_str = initrd_path.to_str().context("Invalid UTF-8")?.to_string();
fs_args = vec!["-initrd".to_string(), initrd_str];
cmdline.push("root=/dev/ram0".into());
} else {
bail!("Registry missing rootfs/initrd for '{name}'. Run `slim build` first.");
}
if let Some(cmd) = &cmd {
let encoded = base64::engine::general_purpose::STANDARD.encode(cmd.as_bytes());
cmdline.push(format!("slim.cmd={encoded}"));
}
// Build 9p shares for each --mount. Tags are short (slim0, slim1, …)
// because 9p mount_tag has a ~31-byte limit. The guest destination path
// is passed on the kernel cmdline as slim.mount=<tag>:<base64(guest_path)>.
// If no guest path is specified, the host path is used as the guest path.
let mut virtfs_args: Vec<String> = Vec::new();
for (i, spec) in mount.iter().enumerate() {
let (host_path, guest_path) = match spec.split_once(':') {
Some((h, g)) => (h, g),
None => (spec.as_str(), spec.as_str()),
};
let canonical = std::path::Path::new(host_path)
.canonicalize()
.with_context(|| format!("Cannot resolve mount path '{host_path}'"))?;
let host_str = canonical
.to_str()
.context("Mount path is not valid UTF-8")?;
let tag = format!("slim{i}");
let dest_b64 = base64::engine::general_purpose::STANDARD.encode(guest_path.as_bytes());
// QEMU's QemuOpts splits on commas — escape literal commas in the
// path as ",," per QEMU convention.
let host_escaped = host_str.replace(',', ",,");
virtfs_args.push("-virtfs".into());
virtfs_args.push(format!(
"local,path={host_escaped},mount_tag={tag},security_model=mapped-xattr"
));
cmdline.push(format!("slim.mount={tag}:{dest_b64}"));
}
println!("Booting {name} from registry: {}", reg_dir.display());
let cmdline = cmdline.join(" ");
// add NIC and forward any user specified ports
let mut network = "user,model=virtio-net-pci".to_string();
for forward in &forward {
_ = write!(&mut network, ",hostfwd={forward}");
}
let mut command = Command::new("qemu-system-x86_64");
// TODO: make a lot of this configurable. especially -cpus
command
.args(["-accel", "kvm"])
.args(["-cpu", "host", "-smp", "cpus=8"])
.args(["-m", &memory])
.arg("-kernel")
.arg(vmlinuz_path.as_os_str())
.args(&fs_args)
.args(&virtfs_args)
.args(["-snapshot"])
.args(["-no-reboot"])
.args(["-append", &cmdline])
.args(["-nographic"])
.args(["-nic", &network]);
println!("{command:?}");
let status = command
.status()
.context("Failed to launch qemu-system-x86_64")?;
match status.code() {
Some(0) => println!("QEMU exited cleanly."),
Some(126) | Some(127) => {
bail!("Failed to start qemu-system-x86_64 (is it installed and in PATH?)")
}
Some(code) => bail!("QEMU exited with code {code}"),
None => bail!("QEMU terminated by a signal"),
}
Ok(())
}