diff options
| author | Nikolay Govorov <me@govorov.online> | 2026-07-12 23:26:12 +0100 |
|---|---|---|
| committer | Nikolay Govorov <me@govorov.online> | 2026-07-12 23:26:12 +0100 |
| commit | 53f3149e1d632f044c28162244304fd3b6b8ea9b (patch) | |
| tree | 75ec1859faf89f2a53cfbc031259e1f4730d3c22 | |
| parent | aceebb27f0cd07c4852f8e927b1a41d307023536 (diff) | |
| download | tar tar.gz tar.bz2 tar.lz tar.xz tar.zst zip | |
Use QMP for managing qemu machine lifecycle
Diffstat
| -rw-r--r-- | Cargo.lock | 2 | +1 −1 |
| -rw-r--r-- | crates/hule-vmm/Cargo.toml | 5 | +5 −0 |
| -rw-r--r-- | crates/hule-vmm/src/backend/qemu/cli.rs | 11 | +11 −0 |
| -rw-r--r-- | crates/hule-vmm/src/backend/qemu/mod.rs | 140 | +121 −19 |
| -rw-r--r-- | crates/hule-vmm/src/backend/qemu/qmp.rs | 165 | +165 −0 |
5 files changed, 303 insertions, 20 deletions
diff --git a/Cargo.lock b/Cargo.lock index 6e8c52a..08e65ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,8 +599,8 @@ name = "hule-vmm" version = "0.1.0" dependencies = [ "async-trait", - "base64", "hule-image", + "serde", "serde_json", "tokio", "uuid", diff --git a/crates/hule-vmm/Cargo.toml b/crates/hule-vmm/Cargo.toml index ed32410..acfc1bf 100644 --- a/crates/hule-vmm/Cargo.toml +++ b/crates/hule-vmm/Cargo.toml @@ -17,3 +17,8 @@ hule-image.workspace = true uuid = { version = "1", features = ["v7"] } tokio = { workspace = true, features = ["process", "fs", "io-util", "sync", "net", "time"] } async-trait = "0.1" +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/hule-vmm/src/backend/qemu/cli.rs b/crates/hule-vmm/src/backend/qemu/cli.rs index b17f8e9..077c111 100644 --- a/crates/hule-vmm/src/backend/qemu/cli.rs +++ b/crates/hule-vmm/src/backend/qemu/cli.rs @@ -147,6 +147,11 @@ pub struct Command { /// `-pidfile`: qemu writes its own pid here. The only reliable way to /// signal/check it later from a process that isn't its parent. pub pidfile: Option<PathBuf>, + /// `-qmp tcp:127.0.0.1:<port>,server=on,wait=off`: the control channel. + /// TCP loopback, not a unix socket -- the latter doesn't exist on + /// Windows at all (`tokio::net::UnixStream` is unix-only), and this way + /// there's nothing to `#[cfg]` on the transport. + pub qmp_port: Option<u16>, } impl Command { @@ -187,6 +192,12 @@ impl Command { if let Some(pidfile) = &self.pidfile { argv.extend(["-pidfile".into(), pidfile.display().to_string()]); } + if let Some(port) = self.qmp_port { + argv.extend([ + "-qmp".into(), + format!("tcp:127.0.0.1:{port},server=on,wait=off"), + ]); + } argv } } diff --git a/crates/hule-vmm/src/backend/qemu/mod.rs b/crates/hule-vmm/src/backend/qemu/mod.rs index 0ae4cd8..75f51c3 100644 --- a/crates/hule-vmm/src/backend/qemu/mod.rs +++ b/crates/hule-vmm/src/backend/qemu/mod.rs @@ -1,17 +1,17 @@ // 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. +//! QMP is an unconditional implementation detail of this backend and backs +//! lifecycle operations that cannot be expressed through process signals. +//! Guest access and console channels are separate concerns and are not wired +//! up here 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 qmp; mod supervise; use std::path::{Path, PathBuf}; @@ -54,6 +54,7 @@ pub struct QemuMachine { disk: Disk, boot: QemuBoot, child: Mutex<ChildState>, + qmp_client: Mutex<Option<qmp::Qmp>>, } impl QemuMachine { @@ -68,6 +69,45 @@ impl QemuMachine { 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") + } + + async fn qmp_port(&self) -> R<u16> { + let data = tokio::fs::read_to_string(self.qmp_port_path()).await?; + data.trim() + .parse() + .map_err(|_| Error::InvalidState("invalid qemu.qmp-port contents".into())) + } + + /// 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> { @@ -127,15 +167,30 @@ impl Machine for QemuMachine { 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, - }, + 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> { @@ -153,6 +208,11 @@ impl Machine for QemuMachine { } 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; + *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 kvm = Architecture::host() == Some(self.arch) && cli::kvm_available().await; let sys_arch = match self.arch { @@ -213,6 +273,7 @@ impl Machine for QemuMachine { } } cmd.pidfile = Some(self.pid_path()); + cmd.qmp_port = Some(qmp_port); let argv = cmd.to_argv(); let wrapper = supervise::spawn(&argv, &self.exit_code_path())?; @@ -221,7 +282,12 @@ impl Machine for QemuMachine { } async fn stop(&mut self) -> R<()> { - todo!("needs QMP system_powerdown, not wired up yet") + self.qmp_client() + .await? + .as_mut() + .expect("initialized by qmp_client") + .power_down() + .await } async fn kill(&mut self) -> R<()> { @@ -240,7 +306,12 @@ impl Machine for QemuMachine { } async fn restart(&mut self) -> R<()> { - todo!("needs QMP system_reset, not wired up yet") + self.qmp_client() + .await? + .as_mut() + .expect("initialized by qmp_client") + .reset() + .await } async fn delete(&mut self) -> R<()> { @@ -249,19 +320,49 @@ impl Machine for QemuMachine { } 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; Ok(()) } async fn pause(&mut self) -> R<()> { - todo!("needs QMP stop, not wired up yet") + self.qmp_client() + .await? + .as_mut() + .expect("initialized by qmp_client") + .pause() + .await } async fn unpause(&mut self) -> R<()> { - todo!("needs QMP cont, not wired up yet") + self.qmp_client() + .await? + .as_mut() + .expect("initialized by qmp_client") + .resume() + .await } - async fn update(&mut self, _settings: Settings) -> R<()> { - todo!("needs a diff against current settings and QMP/hotplug to apply it") + 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<()> { @@ -360,6 +461,7 @@ impl Hypervisor for QemuHypervisor { disk, boot: qemu_boot, child: Mutex::new(ChildState::NotStarted), + qmp_client: Mutex::new(None), })) } diff --git a/crates/hule-vmm/src/backend/qemu/qmp.rs b/crates/hule-vmm/src/backend/qemu/qmp.rs new file mode 100644 --- /dev/null +++ b/crates/hule-vmm/src/backend/qemu/qmp.rs @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: Apache-2.0 + +//! Minimal QMP client: line-delimited JSON commands/responses over the TCP +//! loopback channel qemu exposes via +//! `-qmp tcp:127.0.0.1:<port>,server=on,wait=off`. Events are read and +//! discarded, not surfaced. + +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; + +use crate::{Error, R}; + +pub struct Qmp { + reader: BufReader<OwnedReadHalf>, + writer: OwnedWriteHalf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Status { + Running, + Paused, + Transitional, +} + +impl Status { + fn from_wire(status: &str) -> Self { + match status { + "running" => Self::Running, + "paused" => Self::Paused, + _ => Self::Transitional, + } + } +} + +impl Qmp { + /// Connects and completes the capabilities handshake (greeting -> + /// `qmp_capabilities`), after which arbitrary commands are allowed. + pub async fn connect(port: u16) -> R<Self> { + let (read_half, write_half) = TcpStream::connect(("127.0.0.1", port)).await?.into_split(); + let mut qmp = Self { + reader: BufReader::new(read_half), + writer: write_half, + }; + qmp.read_message().await?; // greeting + qmp.execute("qmp_capabilities", None).await?; + Ok(qmp) + } + + pub async fn status(&mut self) -> R<Status> { + #[derive(Deserialize)] + struct Response { + status: String, + } + + let response: Response = self.execute_typed("query-status", None).await?; + Ok(Status::from_wire(&response.status)) + } + + pub async fn power_down(&mut self) -> R<()> { + self.execute_empty("system_powerdown", None).await + } + + pub async fn reset(&mut self) -> R<()> { + self.execute_empty("system_reset", None).await + } + + pub async fn pause(&mut self) -> R<()> { + self.execute_empty("stop", None).await + } + + pub async fn resume(&mut self) -> R<()> { + self.execute_empty("cont", None).await + } + + pub async fn set_balloon_size(&mut self, bytes: u64) -> R<()> { + self.execute_empty("balloon", Some(json!({ "value": bytes }))) + .await + } + + async fn read_message(&mut self) -> R<Value> { + loop { + let mut line = String::new(); + if self.reader.read_line(&mut line).await? == 0 { + return Err(Error::InvalidState("qmp connection closed".into())); + } + + let line = line.trim(); + if line.is_empty() { + continue; + } + + let value: Value = serde_json::from_str(line) + .map_err(|e| Error::InvalidState(format!("invalid qmp message: {e}")))?; + + // Events arrive unprompted between a command and its response; + // we don't surface them (yet), so skip past them here. + if value.get("event").is_some() { + continue; + } + + return Ok(value); + } + } + + async fn execute(&mut self, command: &str, arguments: Option<Value>) -> R<Value> { + let mut request = json!({ "execute": command }); + if let Some(arguments) = arguments { + request["arguments"] = arguments; + } + let mut bytes = serde_json::to_vec(&request).expect("serializable request"); + bytes.push(b'\n'); + self.writer.write_all(&bytes).await?; + let response = self.read_message().await?; + if let Some(error) = response.get("error") { + return Err(Error::InvalidState(format!( + "qmp '{command}' failed: {error}" + ))); + } + response.get("return").cloned().ok_or_else(|| { + Error::InvalidState(format!("qmp '{command}' response has no return value")) + }) + } + + async fn execute_empty(&mut self, command: &str, arguments: Option<Value>) -> R<()> { + self.execute(command, arguments).await.map(|_| ()) + } + + async fn execute_typed<T: for<'de> Deserialize<'de>>( + &mut self, + command: &str, + arguments: Option<Value>, + ) -> R<T> { + let value = self.execute(command, arguments).await?; + serde_json::from_value(value).map_err(|error| { + Error::InvalidState(format!("invalid qmp '{command}' result: {error}")) + }) + } +} + +/// Picks a free TCP port on 127.0.0.1: binds to port 0 (OS assigns one), +/// reads it back, then drops the listener before qemu binds it for real. +/// Small TOCTOU race in principle; standard practice in the absence of a +/// way to ask qemu what port it actually bound. +pub async fn free_port() -> std::io::Result<u16> { + Ok(tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await? + .local_addr()? + .port()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_status_does_not_escape_the_qmp_boundary() { + assert_eq!(Status::from_wire("running"), Status::Running); + assert_eq!(Status::from_wire("paused"), Status::Paused); + assert_eq!(Status::from_wire("inmigrate"), Status::Transitional); + } +} |
