//! See [`boot`] use anyhow::{Context, Result, bail}; use base64::Engine; use clap::Args; use std::{fmt::Write as _, process::Command}; use crate::{ forward::PortForward, registry::{registry_dir, vmlinuz_path}, virtiofs::{self, VirtiofsShare}, }; #[derive(Args, Debug)] pub struct BootCmd { /// 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, /// Override the command to exec in the VM (base64-encoded on the kernel /// cmdline as `boco.cmd=`). Overrides the CMD inferred at build time. #[clap(long)] cmd: Option, /// Share a host directory into the VM via virtio-fs. Format: /// `` or `:`. 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, } /// Invoke `qemu` to boot a boco image. See [`BootCmd`] for options /// /// This uses qemu's direct-kernel-boot with `-kernel`, so it does not require a bootloader. /// The boco image is just an ext4 rootfs. pub(crate) fn boot( BootCmd { name, memory, forward, cmd, mount, }: BootCmd, ) -> Result<()> { let reg_dir = registry_dir(&name)?; let vmlinuz_path = vmlinuz_path()?; 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. Expected vmlinuz at {}", vmlinuz_path.display() ); } let mut cmdline: Vec = vec![ "console=ttyS0,115200".into(), "rw".into(), "earlyprintk=serial".into(), "nokaslr".into(), "init=/boco/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 `boco build` first."); } if let Some(cmd) = &cmd { let argv = ["/bin/sh".to_string(), "-c".to_string(), cmd.clone()]; let data: Vec = argv.join("\0").into_bytes(); let encoded = base64::engine::general_purpose::STANDARD.encode(&data); cmdline.push(format!("boco.cmd={encoded}")); } // Build virtio-fs shares for each `--mount`. Each share gets its own // virtiofsd daemon under `podman unshare` (rootless UID mapping) and a // matching `-chardev socket` + `-device vhost-user-fs-pci` pair on the // QEMU side. The guest destination path is passed on the kernel cmdline // as `boco.mount=:` and resolved by /boco/init. let mut shares: Vec = Vec::with_capacity(mount.len()); let mut fsdev_args: Vec = Vec::new(); for (i, spec) in mount.iter().enumerate() { let (host_path, guest_path) = match spec.split_once(':') { Some((h, g)) => (h, g.to_string()), None => (spec.as_str(), spec.to_string()), }; let share = virtiofs::spawn_share(i, host_path, guest_path)?; let tag = share.tag.clone(); let chardev = format!("char{i}"); let socket = share .socket_path() .to_str() .context("socket path is not UTF-8")? .to_string(); let dest_b64 = base64::engine::general_purpose::STANDARD.encode(share.guest_path.as_bytes()); fsdev_args.push("-chardev".into()); fsdev_args.push(format!("socket,id={chardev},path={socket}")); fsdev_args.push("-device".into()); fsdev_args.push(format!( "vhost-user-fs-pci,queue-size=1024,chardev={chardev},tag={tag}" )); cmdline.push(format!("boco.mount={tag}:{dest_b64}")); shares.push(share); } // vhost-user devices require shared guest RAM so virtiofsd (a separate // process) can DMA into it. let memory_args: Vec = if shares.is_empty() { vec!["-m".into(), memory.clone()] } else { vec![ "-m".into(), memory.clone(), "-object".into(), format!("memory-backend-memfd,id=mem,size={memory},share=on"), "-numa".into(), "node,memdev=mem".into(), ] }; 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(&memory_args) .arg("-kernel") .arg(vmlinuz_path.as_os_str()) .args(&fs_args) .args(&fsdev_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")?; // Ensure virtiofsd daemons are torn down even on error returns below. drop(shares); 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(()) }