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.rs563+0 −563
1 files changed, 0 insertions, 563 deletions
diff --git a/crates/hule-vmm/src/backend/qemu/mod.rs b/crates/hule-vmm/src/backend/qemu/mod.rs
deleted file mode 100644
--- a/crates/hule-vmm/src/backend/qemu/mod.rs
+++ /dev/null
@@ -1,563 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Nikolay Govorov
-// SPDX-License-Identifier: Apache-2.0
-
-//! QMP is an unconditional implementation detail of this backend. Guest
-//! access is independent: an image may be a black box, or may promise QGA;
-//! a promised QGA channel must become ready for `start()` to succeed.
-//!
-//! `logs`/`attach` use a dedicated serial console; `exec` uses QGA.
-//! Console resizing and `reattach` are not implemented yet.
-//!
-//! `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 qga;
-mod qmp;
-mod supervise;
-
-use std::path::{Path, PathBuf};
-use std::process::{ExitStatus, Output};
-use std::time::Duration;
-
-use async_trait::async_trait;
-use hule_hmi::{Access, 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,
- qga: bool,
- child: Mutex<ChildState>,
- qga_lock: Mutex<()>,
- qmp_client: Mutex<Option<qmp::Qmp>>,
-}
-
-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")
- }
-
- /// Where the port `start()` picked for `-qmp tcp:127.0.0.1:<port>` is
- /// recorded -- chosen by us, not qemu, so (unlike `pid_path`) nothing
- /// needs to wait for this file; it exists before qemu is spawned.
- fn qmp_port_path(&self) -> PathBuf {
- self.dir.join("qemu.qmp-port")
- }
-
- fn qga_port_path(&self) -> PathBuf {
- self.dir.join("qemu.qga-port")
- }
-
- fn console_port_path(&self) -> PathBuf {
- self.dir.join("qemu.console-port")
- }
-
- fn console_log_path(&self) -> PathBuf {
- self.dir.join("console.log")
- }
-
- async fn read_port(&self, path: &Path, name: &str) -> R<u16> {
- let data = tokio::fs::read_to_string(path).await?;
- data.trim()
- .parse()
- .map_err(|_| Error::InvalidState(format!("invalid {name} port contents")))
- }
-
- async fn qmp_port(&self) -> R<u16> {
- self.read_port(&self.qmp_port_path(), "qmp").await
- }
-
- async fn qga_port(&self) -> R<u16> {
- self.read_port(&self.qga_port_path(), "qga").await
- }
-
- async fn console_port(&self) -> R<u16> {
- self.read_port(&self.console_port_path(), "console").await
- }
-
- /// Connects on first use (there's a short window after spawn before
- /// qemu's TCP listener is actually up, so a couple of retries) and
- /// reuses the connection after that.
- async fn qmp_client(&self) -> R<tokio::sync::MutexGuard<'_, Option<qmp::Qmp>>> {
- let mut client = self.qmp_client.lock().await;
- if client.is_none() {
- let port = self.qmp_port().await?;
- let mut connected = None;
- for _ in 0..50 {
- match qmp::Qmp::connect(port).await {
- Ok(qmp) => {
- connected = Some(qmp);
- break;
- }
- Err(_) => tokio::time::sleep(Duration::from_millis(20)).await,
- }
- }
- *client = Some(
- connected
- .ok_or_else(|| Error::InvalidState("qemu's qmp port never came up".into()))?,
- );
- }
- Ok(client)
- }
-
- /// 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));
- }
- let pid = match self.qemu_pid().await {
- Ok(pid) => pid,
- Err(_) => {
- return match &*self.child.lock().await {
- ChildState::NotStarted => State::Created,
- ChildState::Exited(status) => State::Exited(status.code().unwrap_or(-1)),
- ChildState::Running(_) => State::Running,
- };
- }
- };
- if !supervise::pid_alive(pid).await {
- return State::Dead;
- }
- // A live pid alone can't distinguish running from paused; ask QMP.
- if let Ok(mut client) = self.qmp_client().await
- && let Ok(qmp::Status::Paused) = client
- .as_mut()
- .expect("initialized by qmp_client")
- .status()
- .await
- {
- return State::Paused;
- }
- State::Running
- }
-
- async fn stats(&self) -> R<Stats> {
- let pid = self.qemu_pid().await?;
- let (cpu_time_ns, memory_bytes) = supervise::process_stats(pid).await?;
- Ok(Stats {
- cpu_time_ns,
- memory_bytes,
- })
- }
-
- async fn logs(&self) -> R<Vec<u8>> {
- match tokio::fs::read(self.console_log_path()).await {
- Ok(logs) => Ok(logs),
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
- Err(error) => Err(error.into()),
- }
- }
-
- 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 _ = tokio::fs::remove_file(self.qmp_port_path()).await;
- let _ = tokio::fs::remove_file(self.qga_port_path()).await;
- let _ = tokio::fs::remove_file(self.console_port_path()).await;
- let _ = tokio::fs::remove_file(self.console_log_path()).await;
- *self.qmp_client.lock().await = None;
-
- let qmp_port = qmp::free_port().await?;
- tokio::fs::write(self.qmp_port_path(), qmp_port.to_string()).await?;
- let qga_port = if self.qga {
- let mut port = qmp::free_port().await?;
- while port == qmp_port {
- port = qmp::free_port().await?;
- }
- tokio::fs::write(self.qga_port_path(), port.to_string()).await?;
- Some(port)
- } else {
- None
- };
- let mut console_port = qmp::free_port().await?;
- while console_port == qmp_port || Some(console_port) == qga_port {
- console_port = qmp::free_port().await?;
- }
- tokio::fs::write(self.console_port_path(), console_port.to_string()).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());
- cmd.qmp_port = Some(qmp_port);
- cmd.qga_port = qga_port;
- cmd.console = Some((console_port, self.console_log_path()));
- let argv = cmd.to_argv();
-
- let wrapper = supervise::spawn(&argv, &self.exit_code_path())?;
- *child = ChildState::Running(wrapper);
- drop(child);
-
- if let Some(port) = qga_port
- && let Err(error) = qga::wait_until_ready(port, Duration::from_secs(60)).await
- {
- let _ = self.kill().await;
- return Err(error);
- }
- Ok(())
- }
-
- async fn stop(&mut self) -> R<()> {
- self.qmp_client()
- .await?
- .as_mut()
- .expect("initialized by qmp_client")
- .power_down()
- .await
- }
-
- 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<()> {
- self.qmp_client()
- .await?
- .as_mut()
- .expect("initialized by qmp_client")
- .reset()
- .await
- }
-
- 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;
- let _ = tokio::fs::remove_file(self.qmp_port_path()).await;
- let _ = tokio::fs::remove_file(self.qga_port_path()).await;
- let _ = tokio::fs::remove_file(self.console_port_path()).await;
- let _ = tokio::fs::remove_file(self.console_log_path()).await;
- Ok(())
- }
-
- async fn pause(&mut self) -> R<()> {
- self.qmp_client()
- .await?
- .as_mut()
- .expect("initialized by qmp_client")
- .pause()
- .await
- }
-
- async fn unpause(&mut self) -> R<()> {
- self.qmp_client()
- .await?
- .as_mut()
- .expect("initialized by qmp_client")
- .resume()
- .await
- }
-
- async fn update(&mut self, settings: Settings) -> R<()> {
- if settings.cpu != self.settings.cpu {
- return Err(Error::Unsupported(
- "qemu backend can't hot-change cpu count (needs maxcpus reserved at boot)".into(),
- ));
- }
- if settings.port_forwards != self.settings.port_forwards {
- return Err(Error::Unsupported(
- "qemu backend can't hot-change port forwards (fixed at boot via -net user)".into(),
- ));
- }
- if settings.ram != self.settings.ram {
- self.qmp_client()
- .await?
- .as_mut()
- .expect("initialized by qmp_client")
- .set_balloon_size(settings.ram * 1024 * 1024)
- .await?;
- }
- self.settings = settings;
- Ok(())
- }
-
- 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> {
- if !self.qga {
- return Err(Error::Unsupported(
- "this image does not declare qga access".into(),
- ));
- }
- let _guard = self.qga_lock.lock().await;
- qga::exec(self.qga_port().await?, cmd).await
- }
-
- async fn attach(&self) -> R<Box<dyn Console>> {
- let port = self.console_port().await?;
- for _ in 0..50 {
- match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
- Ok(stream) => return Ok(Box::new(stream)),
- Err(_) => tokio::time::sleep(Duration::from_millis(20)).await,
- }
- }
- Err(Error::InvalidState(
- "qemu serial console did not become available".into(),
- ))
- }
-}
-
-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,
- qga: image
- .machine
- .access
- .iter()
- .any(|access| matches!(access, Access::Qga { .. })),
- child: Mutex::new(ChildState::NotStarted),
- qga_lock: Mutex::new(()),
- qmp_client: Mutex::new(None),
- }))
- }
-
- 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")
- }
-}