pub mod disk;
pub mod display;
pub mod download;
pub mod mount;
pub mod net;
pub mod snapshot;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use crate::config::{ensure_dirs, monitor_dir, DisplayMode, GuestOs, Result, TestbedError, VmProfile};
use tracing::debug;
pub struct QemuVm {
process: Option<Child>,
monitor: UnixStream,
pub profile: VmProfile,
pub disk_path: std::path::PathBuf,
pub pid: u32,
pub resolved_ports: ResolvedPorts,
}
#[derive(Debug, Clone)]
pub struct ResolvedPorts {
pub ssh_port: u16,
pub winrm_port: Option<u16>,
pub rdp_port: Option<u16>,
pub vnc_port: u16,
}
#[derive(Debug, Clone)]
pub struct QemuConfig {
profile: VmProfile,
display_mode: DisplayMode,
extra_args: Vec<String>,
project_mount: Option<std::path::PathBuf>,
project_mount_guest_path: Option<String>,
project_mount_readonly: bool,
cdrom_path: Option<std::path::PathBuf>,
}
impl QemuConfig {
pub fn new(profile: VmProfile, display_mode: DisplayMode) -> Self {
Self {
profile,
display_mode,
extra_args: Vec::new(),
project_mount: None,
project_mount_guest_path: None,
project_mount_readonly: false,
cdrom_path: None,
}
}
pub fn with_extra_arg(mut self, arg: String) -> Self {
self.extra_args.push(arg);
self
}
pub fn with_project_mount(
mut self,
host_path: std::path::PathBuf,
guest_path: &str,
readonly: bool,
) -> Self {
self.project_mount = Some(host_path);
self.project_mount_guest_path = Some(guest_path.to_string());
self.project_mount_readonly = readonly;
self
}
pub fn project_mount_guest_path(&self) -> Option<&str> {
self.project_mount_guest_path.as_deref()
}
pub fn with_cdrom(mut self, path: std::path::PathBuf) -> Self {
self.cdrom_path = Some(path);
self
}
pub fn launch(self) -> Result<QemuVm> {
ensure_dirs().map_err(|e| TestbedError::Qcow2Error {
message: format!("failed to create cache dirs: {e}"),
})?;
let qemu_bin = find_qemu_system()?;
let resolved = net::allocate_ports(&self.profile)?;
let monitor_dir_path = monitor_dir(&self.profile.name);
std::fs::create_dir_all(&monitor_dir_path).ok();
let monitor_path = monitor_dir_path.join(format!("{}.monitor", self.profile.name));
let _ = std::fs::remove_file(&monitor_path); eprintln!("DEBUG: monitor_path = {:?}", monitor_path);
let disk_path = crate::import::ensure_image(&self.profile)?;
let mut config = self;
if config.profile.os == GuestOs::Windows {
if let Ok(iso_path) = crate::import::ensure_virtio_iso() {
config = config.with_cdrom(iso_path);
}
}
let (macos_boot_disk, macos_efi_disk) = if config.profile.os == GuestOs::MacOS {
let boot = crate::import::macos::ensure_macos_image(config.profile.name)?;
let base_dir = boot.parent().unwrap().to_path_buf();
let efi = base_dir.join(crate::import::macos::OPENCORE_EFI_FILENAME);
(Some(boot), Some(efi))
} else {
(None, None)
};
let mut cmd = Command::new(&qemu_bin);
let (display_args, has_native_window) = if config.display_mode == DisplayMode::Headful {
let backend = display::detect_backend();
let vnc_offset = (resolved.vnc_port - 5900) as u32;
let args = backend.qemu_args(vnc_offset);
let has_native = matches!(backend, display::DisplayBackend::Spice | display::DisplayBackend::Gtk);
(args, has_native)
} else {
let vnc_offset = (resolved.vnc_port - 5900) as u32;
(display::DisplayBackend::Vnc.qemu_args(vnc_offset), false)
};
let actual_disk = macos_boot_disk.as_ref().unwrap_or(&disk_path);
if config.profile.os == GuestOs::Windows {
if let Some(host_path) = &config.project_mount {
let socket_path = monitor_path.with_file_name(format!("{}.virtiofsd.sock", config.profile.name));
let _ = std::fs::remove_file(&socket_path);
let (_, daemon_config) = mount::virtiofs_args(
host_path,
mount::DEFAULT_TAG,
&socket_path,
config.project_mount_readonly,
);
spawn_virtiofsd(&daemon_config)?;
}
}
cmd.args(build_qemu_args(
&config.profile,
actual_disk,
&resolved,
&monitor_path,
display_args,
has_native_window,
config.project_mount.as_deref(),
config.project_mount_readonly,
config.cdrom_path.as_deref(),
macos_efi_disk.as_deref(),
));
cmd.args(&config.extra_args);
let work_dir = crate::config::work_dir(&config.profile.name);
let _ = std::fs::create_dir_all(&work_dir);
let qemu_log = work_dir.join("qemu.log");
let qemu_log_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&qemu_log)
.ok();
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
if let Some(log) = &qemu_log_file {
cmd.stderr(log.try_clone().unwrap());
} else {
cmd.stderr(Stdio::piped());
}
let mut process = cmd.spawn().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => TestbedError::QemuNotFound {
install_cmd: "mise install qemu".to_string(),
},
_ => TestbedError::Qcow2Error {
message: format!("failed to spawn QEMU: {e}"),
},
})?;
let deadline = Instant::now() + Duration::from_secs(5);
let monitor = loop {
if Instant::now() > deadline {
process.kill().ok();
return Err(TestbedError::QemuExited {
code: process.try_wait().ok().flatten().map(|s| s.code().unwrap_or(-1)).unwrap_or(-1),
});
}
match UnixStream::connect(&monitor_path) {
Ok(s) => break s,
Err(_) => thread::sleep(Duration::from_millis(100)),
}
};
monitor.set_nonblocking(true).ok();
let pid = process.id();
if config.display_mode == DisplayMode::Headful && !has_native_window {
if let Some(name) = display::launch_viewer(display::DisplayBackend::Vnc, resolved.vnc_port) {
eprintln!(" Viewer: {name} on 127.0.0.1:{}", resolved.vnc_port);
} else {
eprintln!(" No VNC viewer found. Connect manually to 127.0.0.1:{}", resolved.vnc_port);
}
}
Ok(QemuVm {
process: Some(process),
monitor,
profile: config.profile,
disk_path,
pid,
resolved_ports: resolved,
})
}
}
impl QemuVm {
pub fn adopt_running(
profile: VmProfile,
disk_path: std::path::PathBuf,
pid: u32,
monitor_path: &std::path::Path,
resolved_ports: ResolvedPorts,
) -> Result<Self> {
let monitor = UnixStream::connect(monitor_path).map_err(|e| TestbedError::Qcow2Error {
message: format!("connecting to monitor socket {monitor_path:?}: {e}"),
})?;
monitor.set_nonblocking(true).ok();
Ok(QemuVm {
process: None, monitor,
profile,
disk_path,
pid,
resolved_ports,
})
}
pub fn is_running(&mut self) -> bool {
if let Some(ref mut child) = self.process {
matches!(child.try_wait(), Ok(None))
} else {
std::path::Path::new(&format!("/proc/{}", self.pid)).exists()
}
}
pub fn pid(&self) -> u32 {
self.pid
}
pub fn shutdown(&mut self) -> Result<()> {
if !self.is_running() {
return Ok(()); }
if self.monitor_command("system_powerdown").is_ok() {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
thread::sleep(Duration::from_millis(500));
if !self.is_running() {
return Ok(());
}
}
}
if let Some(ref mut child) = self.process {
let _ = child.kill();
} else {
unsafe { libc::kill(self.pid as i32, libc::SIGTERM) };
for _ in 0..30 {
thread::sleep(Duration::from_millis(500));
if !std::path::Path::new(&format!("/proc/{}", self.pid)).exists() {
return Ok(());
}
}
unsafe { libc::kill(self.pid as i32, libc::SIGKILL) };
}
if let Some(ref mut child) = self.process {
child.wait().ok();
}
let monitor_path = monitor_dir(&self.profile.name).join(format!("{}.monitor", self.profile.name));
let _ = std::fs::remove_file(&monitor_path);
Ok(())
}
pub fn monitor_command(&mut self, cmd: &str) -> Result<String> {
self.monitor
.write_all(format!("{cmd}\n").as_bytes())
.map_err(|e| TestbedError::MonitorFailed {
source: anyhow::anyhow!("writing monitor command: {e}"),
})?;
self.monitor
.flush()
.map_err(|e| TestbedError::MonitorFailed {
source: anyhow::anyhow!("flushing monitor command: {e}"),
})?;
let reader = BufReader::new(&self.monitor);
let mut output = String::new();
let deadline = Instant::now() + Duration::from_secs(5);
for line in reader.lines() {
if Instant::now() > deadline {
return Err(TestbedError::MonitorFailed {
source: anyhow::anyhow!("monitor response timeout"),
});
}
match line {
Ok(l) => {
if l.contains("(qemu)") {
break;
}
if !l.is_empty() && l != cmd {
output.push_str(&l);
output.push('\n');
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(50));
continue;
}
Err(e) => {
return Err(TestbedError::MonitorFailed {
source: anyhow::anyhow!("reading monitor response: {e}"),
});
}
}
}
Ok(output.trim().to_string())
}
pub fn wait(self) -> Result<Option<i32>> {
let Some(mut child) = self.process else {
return Ok(None); };
let status = child.wait().map_err(|e| TestbedError::Qcow2Error {
message: format!("waiting for QEMU process: {e}"),
})?;
Ok(status.code())
}
}
pub fn adopt(
profile: &VmProfile,
disk_path: &std::path::Path,
) -> Result<(VmProfile, std::path::PathBuf)> {
if !disk_path.exists() {
return Err(TestbedError::Qcow2Error {
message: format!("disk file not found: {}", disk_path.display()),
});
}
let meta = std::fs::metadata(disk_path).map_err(|e| TestbedError::Qcow2Error {
message: format!("stat {}: {e}", disk_path.display()),
})?;
if meta.len() == 0 {
return Err(TestbedError::Qcow2Error {
message: format!("disk file is empty: {}", disk_path.display()),
});
}
let cache_dir = crate::config::image_cache_dir();
let dest = cache_dir.join(format!("adopted-{}.qcow2", profile.name));
std::fs::copy(disk_path, &dest).map_err(|e| TestbedError::Qcow2Error {
message: format!("copy {} → {}: {e}", disk_path.display(), dest.display()),
})?;
crate::state::save(&crate::state::VmState {
profile_name: profile.name.to_string(),
disk_path: dest.to_string_lossy().to_string(),
pid: None,
provider_id: crate::providers::ProviderId::Qemu,
provider_internal_id: String::new(),
monitor_socket: String::new(),
ssh_port: profile.ssh_port,
winrm_port: profile.winrm_port,
rdp_port: profile.rdp_port,
vnc_port: profile.vnc_port,
bootstrapped: false,
created_at: chrono::Utc::now().to_rfc3339(),
})?;
Ok((profile.clone(), dest))
}
pub fn refresh_network(
profile: &VmProfile,
display_mode: DisplayMode,
) -> Result<QemuVm> {
if let Ok(state) = crate::state::load(profile.name)
&& let Some(pid) = state.pid {
let alive = std::path::Path::new(&format!("/proc/{pid}")).exists();
if alive {
if let Ok(mut vm) = QemuConfig::new(profile.clone(), display_mode).launch() {
vm.shutdown()?;
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while std::time::Instant::now() < deadline {
if !std::path::Path::new(&format!("/proc/{pid}")).exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
}
let vm = QemuConfig::new(profile.clone(), display_mode).launch()?;
crate::state::save(&crate::state::VmState {
profile_name: profile.name.to_string(),
disk_path: vm.disk_path.to_string_lossy().to_string(),
pid: Some(vm.pid),
provider_id: crate::providers::ProviderId::Qemu,
provider_internal_id: vm.pid.to_string(),
monitor_socket: String::new(),
ssh_port: vm.resolved_ports.ssh_port,
winrm_port: vm.resolved_ports.winrm_port,
rdp_port: vm.resolved_ports.rdp_port,
vnc_port: vm.resolved_ports.vnc_port,
bootstrapped: false,
created_at: chrono::Utc::now().to_rfc3339(),
})?;
Ok(vm)
}
pub fn build_qemu_args(
profile: &VmProfile,
disk_path: &std::path::Path,
ports: &ResolvedPorts,
monitor_path: &std::path::Path,
display_args: Vec<String>,
_has_native_window: bool,
project_mount: Option<&std::path::Path>,
project_mount_readonly: bool,
cdrom_path: Option<&std::path::Path>,
macos_efi_disk: Option<&std::path::Path>,
) -> Vec<String> {
let mut args = Vec::new();
if kvm_available() {
args.push("-enable-kvm".to_string());
}
args.push("-m".to_string());
args.push(profile.memory_mib.to_string());
args.push("-smp".to_string());
args.push(profile.cpu_cores.to_string());
if profile.os == GuestOs::MacOS {
args.push("-cpu".to_string());
args.push("Penryn,kvm=on,vendor=GenuineIntel,+invtsc,vmware-cpuid-freq=on".to_string());
args.push("-machine".to_string());
args.push("q35".to_string());
} else {
args.push("-cpu".to_string());
args.push("host".to_string());
}
if profile.os == GuestOs::Windows {
let vars_path = crate::config::state_dir().join(format!("{}.nvram", profile.name));
if !vars_path.exists() {
let _ = std::fs::copy("/usr/share/edk2/x64/OVMF_VARS.4m.fd", &vars_path);
}
args.push("-drive".to_string());
args.push("file=/usr/share/edk2/x64/OVMF_CODE.4m.fd,if=pflash,format=raw,readonly=on".to_string());
args.push("-drive".to_string());
args.push(format!("file={},if=pflash,format=raw", vars_path.display()));
}
if profile.os == GuestOs::MacOS {
args.push("-device".to_string());
args.push("isa-applesmc,osk=\"ourhardworkbythesewordsguardedpleasedontsteal(c)AppleComputerInc\"".to_string());
args.push("-device".to_string());
args.push("qemu-xhci".to_string());
args.push("-device".to_string());
args.push("usb-kbd".to_string());
args.push("-device".to_string());
args.push("usb-tablet".to_string());
args.push("-device".to_string());
args.push("ich9-ahci,id=sata".to_string());
args.push("-device".to_string());
args.push("ich9-intel-hda".to_string());
args.push("-device".to_string());
args.push("hda-output".to_string());
}
if profile.os == GuestOs::MacOS {
if let Some(efi_disk) = macos_efi_disk {
args.push("-drive".to_string());
args.push(format!(
"file={},format=qcow2,if=none,id=OpenCore",
efi_disk.display()
));
args.push("-device".to_string());
args.push("ide-hd,bus=sata.1,drive=OpenCore".to_string());
}
args.push("-drive".to_string());
args.push(format!(
"file={},format=qcow2,if=none,id=macOS",
disk_path.display()
));
args.push("-device".to_string());
args.push("ide-hd,bus=sata.2,drive=macOS".to_string());
if let Some(data_dir) = disk_path.parent() {
let data_disk = data_dir.join("macOS-data.qcow2");
if data_disk.exists() {
args.push("-drive".to_string());
args.push(format!(
"file={},format=qcow2,if=none,id=MacData",
data_disk.display()
));
args.push("-device".to_string());
args.push("ide-hd,bus=sata.3,drive=MacData".to_string());
}
}
} else {
args.push("-drive".to_string());
args.push(format!(
"file={},format=qcow2,if=virtio",
disk_path.display()
));
}
if let Some(cdrom) = cdrom_path {
args.push("-drive".to_string());
args.push(format!("file={},media=cdrom", cdrom.display()));
}
if let Some(host_path) = project_mount {
if profile.os == GuestOs::Windows {
args.push("-object".to_string());
args.push(format!("memory-backend-memfd,id=mem,size={}M,share=on", profile.memory_mib));
args.push("-machine".to_string());
args.push("memory-backend=mem".to_string());
let socket_path = monitor_path.with_file_name(format!("{}.virtiofsd.sock", profile.name));
let (vfs_args, _daemon) = mount::virtiofs_args(
host_path,
mount::DEFAULT_TAG,
&socket_path,
project_mount_readonly,
);
args.extend(vfs_args);
} else {
args.extend(mount::mount_args(host_path, mount::DEFAULT_TAG, project_mount_readonly));
}
}
let mut netdev = "user,id=net".to_string();
netdev.push_str(&format!(",hostfwd=tcp:127.0.0.1:{}-:22", ports.ssh_port));
if let Some(p) = ports.winrm_port {
netdev.push_str(&format!(",hostfwd=tcp:127.0.0.1:{p}-:5985"));
}
if let Some(p) = ports.rdp_port {
netdev.push_str(&format!(",hostfwd=tcp:127.0.0.1:{p}-:3389"));
}
if profile.os == GuestOs::Windows && project_mount.is_some() {
if let Some(host_path) = project_mount {
netdev.push_str(&format!(",smb={}", host_path.display()));
}
}
args.push("-netdev".to_string());
args.push(netdev);
args.push("-device".to_string());
args.push("virtio-net-pci,netdev=net".to_string());
if profile.os != GuestOs::MacOS {
args.push("-vga".to_string());
args.push("std".to_string());
}
if profile.os != GuestOs::MacOS {
args.push("-usb".to_string());
args.push("-device".to_string());
args.push("usb-tablet".to_string());
}
args.push("-monitor".to_string());
args.push(format!(
"unix:{},server,nowait",
monitor_path.display()
));
args.extend(display_args);
args.push("-no-reboot".to_string());
args
}
fn kvm_available() -> bool {
let path = std::path::Path::new("/dev/kvm");
path.exists()
&& std::fs::metadata(path)
.map(|m| !m.permissions().readonly()) .unwrap_or(false)
}
fn find_qemu_system() -> Result<std::path::PathBuf> {
if let Ok(p) = which::which("qemu-system-x86_64") {
return Ok(p);
}
let candidates = [
"/opt/homebrew/bin/qemu-system-x86_64",
"/usr/local/bin/qemu-system-x86_64",
"/run/current-system/sw/bin/qemu-system-x86_64",
];
for c in &candidates {
let p = std::path::PathBuf::from(c);
if p.exists() {
return Ok(p);
}
}
Err(TestbedError::QemuNotFound {
install_cmd: "mise install qemu".to_string(),
})
}
fn spawn_virtiofsd(config: &mount::VirtiofsdConfig) -> Result<()> {
let _ = std::fs::remove_file(&config.socket_path);
let pid_path = config.socket_path.with_extension("sock.pid");
let _ = std::fs::remove_file(&pid_path);
let mut child = config.command()
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn().map_err(|e| TestbedError::Qcow2Error {
message: format!("failed to spawn virtiofsd: {e}"),
})?;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if let Some(status) = child.try_wait().map_err(|e| TestbedError::Qcow2Error {
message: format!("virtiofsd child error: {e}"),
})? {
drop(child);
return Err(TestbedError::Qcow2Error {
message: format!("virtiofsd exited early with status: {status}"),
});
}
if config.socket_path.exists() || pid_path.exists() {
debug!("virtiofsd started, socket at {}", config.socket_path.display());
std::mem::forget(child);
return Ok(());
}
thread::sleep(Duration::from_millis(200));
}
drop(child);
Err(TestbedError::Qcow2Error {
message: format!("virtiofsd did not start at {}", config.socket_path.display()),
})
}
pub fn stop_virtiofsd(socket_path: &std::path::Path) {
let _ = std::fs::remove_file(socket_path);
}