aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to 'crates/hule-vmm/src/backend/qemu/mod.rs')
-rw-r--r--crates/hule-vmm/src/backend/qemu/mod.rs375+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")
+ }
+}