diff options
Diffstat (limited to 'crates/hule-vmm/src/backend/qemu/mod.rs')
| -rw-r--r-- | crates/hule-vmm/src/backend/qemu/mod.rs | 375 | +375 −0 |
1 files changed, 375 insertions, 0 deletions
diff --git a/crates/hule-vmm/src/backend/qemu/mod.rs b/crates/hule-vmm/src/backend/qemu/mod.rs new file mode 100644 --- /dev/null +++ b/crates/hule-vmm/src/backend/qemu/mod.rs @@ -0,0 +1,375 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: Apache-2.0 + +//! qemu backend. Basic extraction from the old `hule` binary's boot code: +//! `start`/`kill`/`wait`/`state`/`delete` work, everything needing QMP or a +//! console channel (`stop`, `restart`, `pause`, `exec`, `attach`, ...) is +//! `todo!()` -- QMP is opportunistic, for operations signals can't do, not +//! load-bearing for the base lifecycle. +//! +//! `cli` is the typed qemu CLI wrapper; `supervise` gets an exit code out of +//! a process even if we stop being its parent; this module only translates +//! Hule's image/machine types into both. + +mod cli; +mod supervise; + +use std::path::{Path, PathBuf}; +use std::process::{ExitStatus, Output}; +use std::time::Duration; + +use async_trait::async_trait; +use hule_image::{Architecture, Boot, BootLinux, Disk, MachineImage}; +use tokio::process::Child; +use tokio::sync::Mutex; + +use crate::{ + Console, Error, Hypervisor, HypervisorId, Machine, MachineId, R, Settings, State, Stats, +}; + +const QEMU_HYPERVISOR_ID: HypervisorId = HypervisorId(1); + +enum QemuBoot { + // No BootBios fields are needed here: `disk` is already resolved into + // `QemuMachine::disk`, and `protocol` isn't used past `supports()`. + Bios, + Linux(BootLinux), +} + +/// The wrapper (`sh`) process, not qemu itself -- once qemu has a pid on +/// disk (see `QemuMachine::pid_path`), that's the source of truth for +/// liveness/signaling, not this. +enum ChildState { + NotStarted, + Running(Child), + Exited(ExitStatus), +} + +pub struct QemuMachine { + id: MachineId, + name: Option<String>, + dir: PathBuf, + arch: Architecture, + settings: Settings, + disk: Disk, + boot: QemuBoot, + child: Mutex<ChildState>, +} + +impl QemuMachine { + /// Where qemu's own `-pidfile` lands. Provisional: once a persisted + /// `run/<id>/` directory exists (separate from `dir`, the materialized + /// image files), this moves there instead of living inside `dir`. + fn pid_path(&self) -> PathBuf { + self.dir.join("qemu.pid") + } + + fn exit_code_path(&self) -> PathBuf { + self.dir.join("qemu.exit-code") + } + + /// Reads qemu's pid, waiting briefly for `-pidfile` to actually appear + /// (there's a short window between spawn and qemu writing it). + async fn qemu_pid(&self) -> R<supervise::Pid> { + for _ in 0..50 { + if let Ok(data) = tokio::fs::read_to_string(self.pid_path()).await + && let Ok(pid) = data.trim().parse() + { + return Ok(supervise::Pid(pid)); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(Error::InvalidState( + "qemu did not write its pidfile in time".into(), + )) + } + + async fn read_exit_code(&self) -> Option<ExitStatus> { + let data = tokio::fs::read_to_string(self.exit_code_path()) + .await + .ok()?; + let code: i32 = data.trim().parse().ok()?; + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + // `$?` is already qemu's plain exit code; shift it into the + // wait()-status shape ExitStatus expects (bits 8-15 == exit + // code, low byte 0 == not signaled). Loses signal detail, but + // `.code()` -- all we ever read -- comes back right. + Some(ExitStatus::from_raw(code << 8)) + } + #[cfg(not(unix))] + { + None + } + } +} + +#[async_trait] +impl Machine for QemuMachine { + fn id(&self) -> MachineId { + self.id + } + + fn hid(&self) -> HypervisorId { + QEMU_HYPERVISOR_ID + } + + fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + fn settings(&self) -> Settings { + self.settings.clone() + } + + async fn state(&self) -> State { + if let Some(status) = self.read_exit_code().await { + return State::Exited(status.code().unwrap_or(-1)); + } + match self.qemu_pid().await { + Ok(pid) if supervise::pid_alive(pid).await => State::Running, + Ok(_) => State::Dead, + Err(_) => match &*self.child.lock().await { + ChildState::NotStarted => State::Created, + ChildState::Exited(status) => State::Exited(status.code().unwrap_or(-1)), + ChildState::Running(_) => State::Running, + }, + } + } + + async fn stats(&self) -> R<Stats> { + todo!("needs /proc or QMP query-blockstats wiring") + } + + async fn logs(&self) -> R<Vec<u8>> { + todo!("needs a captured serial console, not wired up yet") + } + + async fn start(&mut self) -> R<()> { + let mut child = self.child.lock().await; + if matches!(*child, ChildState::Running(_)) { + return Err(Error::InvalidState("already started".into())); + } + let _ = tokio::fs::remove_file(self.pid_path()).await; + let _ = tokio::fs::remove_file(self.exit_code_path()).await; + + let kvm = Architecture::host() == Some(self.arch) && cli::kvm_available().await; + let sys_arch = match self.arch { + Architecture::Amd64 => cli::Arch::Amd64, + Architecture::Arm64 => cli::Arch::Arm64, + Architecture::Loong64 => cli::Arch::Loong64, + Architecture::Ppc64le => cli::Arch::Ppc64le, + Architecture::Riscv64 => cli::Arch::Riscv64, + Architecture::S390x => cli::Arch::S390x, + }; + let mut cmd = cli::Command { + binary: sys_arch.binary(), + machine: sys_arch.machine_type().map(String::from), + memory_mib: self.settings.ram, + smp: self.settings.cpu, + display_none: true, + nets: vec![ + cli::NetBackend::Nic { + model: "virtio".into(), + }, + cli::NetBackend::User { + hostfwd: self.settings.port_forwards.clone(), + }, + ], + devices: vec![cli::Device::VirtioRngPci, cli::Device::VirtioBalloon], + ..Default::default() + }; + + if kvm { + cmd.cpu = Some("host".into()); + cmd.accel = Some(cli::Accel::Kvm); + } else { + cmd.cpu = Some(sys_arch.tcg_cpu_model().into()); + cmd.accel = Some(cli::Accel::Tcg); + } + + let disk_path = self.dir.join(&self.disk.path); + match &self.boot { + QemuBoot::Bios => { + cmd.drives.push(cli::Drive { + file: disk_path, + snapshot: true, + interface: cli::DriveInterface::Virtio, + }); + } + QemuBoot::Linux(linux) => { + cmd.drives.push(cli::Drive { + file: disk_path, + snapshot: true, + interface: cli::DriveInterface::None { id: "root".into() }, + }); + cmd.devices.push(cli::Device::VirtioBlkPci { + drive: "root".into(), + }); + cmd.kernel = Some(self.dir.join(&linux.kernel.path)); + cmd.initrd = Some(self.dir.join(&linux.initrd.path)); + cmd.append = Some(linux.cmdline.clone()); + } + } + cmd.pidfile = Some(self.pid_path()); + let argv = cmd.to_argv(); + + let wrapper = supervise::spawn(&argv, &self.exit_code_path())?; + *child = ChildState::Running(wrapper); + Ok(()) + } + + async fn stop(&mut self) -> R<()> { + todo!("needs QMP system_powerdown, not wired up yet") + } + + async fn kill(&mut self) -> R<()> { + let pid = self.qemu_pid().await?; + supervise::kill(pid).await?; + // Reap the wrapper so its resources are released; its own exit + // status is uninteresting, qemu's (now on disk) is what matters. + let mut child = self.child.lock().await; + if let ChildState::Running(c) = &mut *child { + let _ = c.wait().await; + } + if let Some(status) = self.read_exit_code().await { + *child = ChildState::Exited(status); + } + Ok(()) + } + + async fn restart(&mut self) -> R<()> { + todo!("needs QMP system_reset, not wired up yet") + } + + async fn delete(&mut self) -> R<()> { + if self.state().await == State::Running { + self.kill().await?; + } + let _ = tokio::fs::remove_file(self.pid_path()).await; + let _ = tokio::fs::remove_file(self.exit_code_path()).await; + Ok(()) + } + + async fn pause(&mut self) -> R<()> { + todo!("needs QMP stop, not wired up yet") + } + + async fn unpause(&mut self) -> R<()> { + todo!("needs QMP cont, not wired up yet") + } + + async fn update(&mut self, _settings: Settings) -> R<()> { + todo!("needs a diff against current settings and QMP/hotplug to apply it") + } + + async fn rename(&mut self, name: Option<&str>) -> R<()> { + self.name = name.map(String::from); + Ok(()) + } + + async fn wait(&mut self) -> R<ExitStatus> { + loop { + if let Some(status) = self.read_exit_code().await { + *self.child.lock().await = ChildState::Exited(status); + return Ok(status); + } + let mut child = self.child.lock().await; + match &mut *child { + ChildState::Running(c) => { + // The wrapper only exits after qemu does and the exit + // code is flushed to disk, so looping back re-reads it. + c.wait().await?; + } + ChildState::Exited(status) => return Ok(*status), + ChildState::NotStarted => { + return Err(Error::InvalidState("not started".into())); + } + } + } + } + + async fn exec(&self, _cmd: &[String]) -> R<Output> { + todo!("needs the access channel (ssh today) wired up here") + } + + async fn attach(&self) -> R<Box<dyn Console>> { + todo!("needs a serial console channel, not wired up yet") + } + + async fn resize(&self, _cols: u16, _rows: u16) -> R<()> { + todo!("depends on attach") + } +} + +pub struct QemuHypervisor; + +#[async_trait] +impl Hypervisor for QemuHypervisor { + fn id(&self) -> HypervisorId { + QEMU_HYPERVISOR_ID + } + + fn name(&self) -> &'static str { + "qemu" + } + + fn supports(&self, boot: &Boot) -> bool { + matches!(boot, Boot::Bios(_) | Boot::Linux(_)) + } + + async fn create( + &self, + id: MachineId, + name: Option<&str>, + image: &MachineImage, + dir: &Path, + boot: &Boot, + settings: &Settings, + ) -> R<Box<dyn Machine>> { + let disk = image + .disks + .iter() + .find(|d| d.id == boot.disk()) + .cloned() + .ok_or_else(|| { + Error::InvalidImage(format!( + "boot entry references unknown disk '{}'", + boot.disk() + )) + })?; + + let qemu_boot = match boot { + Boot::Bios(_) => QemuBoot::Bios, + Boot::Linux(b) => QemuBoot::Linux(b.clone()), + Boot::Uefi(_) | Boot::Unknown(_) => { + return Err(Error::Unsupported(format!( + "qemu backend cannot boot protocol '{}'", + boot.protocol() + ))); + } + }; + + Ok(Box::new(QemuMachine { + id, + name: name.map(String::from), + dir: dir.to_path_buf(), + arch: image.system.architecture, + settings: settings.clone(), + disk, + boot: qemu_boot, + child: Mutex::new(ChildState::NotStarted), + })) + } + + async fn reattach( + &self, + _id: MachineId, + _name: Option<&str>, + _dir: &Path, + _settings: &Settings, + ) -> R<Box<dyn Machine>> { + todo!("no persisted qemu run-state to reattach to yet") + } +} |
