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.rs120+103 −17
1 files changed, 103 insertions, 17 deletions
diff --git a/crates/hule-vmm/src/backend/qemu/mod.rs b/crates/hule-vmm/src/backend/qemu/mod.rs
index 75f51c3..0cd47cb 100644
--- a/crates/hule-vmm/src/backend/qemu/mod.rs
+++ b/crates/hule-vmm/src/backend/qemu/mod.rs
@@ -1,16 +1,19 @@
// SPDX-FileCopyrightText: 2026 Nikolay Govorov
// SPDX-License-Identifier: Apache-2.0
-//! 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.
+//! 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;
@@ -19,7 +22,7 @@ use std::process::{ExitStatus, Output};
use std::time::Duration;
use async_trait::async_trait;
-use hule_image::{Architecture, Boot, BootLinux, Disk, MachineImage};
+use hule_image::{Access, Architecture, Boot, BootLinux, Disk, MachineImage};
use tokio::process::Child;
use tokio::sync::Mutex;
@@ -53,7 +56,9 @@ pub struct QemuMachine {
settings: Settings,
disk: Disk,
boot: QemuBoot,
+ qga: bool,
child: Mutex<ChildState>,
+ qga_lock: Mutex<()>,
qmp_client: Mutex<Option<qmp::Qmp>>,
}
@@ -76,11 +81,35 @@ impl QemuMachine {
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?;
+ 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("invalid qemu.qmp-port contents".into()))
+ .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
@@ -194,11 +223,20 @@ impl Machine for QemuMachine {
}
async fn stats(&self) -> R<Stats> {
- todo!("needs /proc or QMP query-blockstats wiring")
+ 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>> {
- todo!("needs a captured serial console, not wired up yet")
+ 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<()> {
@@ -209,10 +247,28 @@ 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;
+ 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 {
@@ -274,10 +330,20 @@ impl Machine for QemuMachine {
}
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(())
}
@@ -321,6 +387,9 @@ 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;
+ 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(())
}
@@ -391,16 +460,27 @@ impl Machine for QemuMachine {
}
}
- async fn exec(&self, _cmd: &[String]) -> R<Output> {
- todo!("needs the access channel (ssh today) wired up here")
+ 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>> {
- todo!("needs a serial console channel, not wired up yet")
- }
-
- async fn resize(&self, _cols: u16, _rows: u16) -> R<()> {
- todo!("depends on attach")
+ 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(),
+ ))
}
}
@@ -460,7 +540,13 @@ impl Hypervisor for QemuHypervisor {
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),
}))
}