//! KVM/QEMU hypervisor backend for PlasmaVMC //! //! This crate provides the KVM backend implementation for the HypervisorBackend trait. //! It uses QEMU with KVM acceleration to run virtual machines. mod env; mod qmp; use async_trait::async_trait; use env::{ resolve_kernel_initrd, resolve_nbd_aio_mode, resolve_nbd_max_queues, resolve_qcow2_path, resolve_qemu_path, resolve_qmp_timeout_secs, resolve_runtime_dir, ENV_QCOW2_PATH, }; use plasmavmc_hypervisor::{BackendCapabilities, HypervisorBackend, UnsupportedReason}; use plasmavmc_types::{ AttachedDisk, DiskAttachment, DiskBus, DiskCache, Error, HypervisorType, NetworkSpec, NicModel, Result, VirtualMachine, VmHandle, VmSpec, VmState, VmStatus, VolumeFormat, }; use qmp::QmpClient; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; use tokio::process::Command; use tokio::{net::UnixStream, time::Instant}; /// KVM/QEMU hypervisor backend pub struct KvmBackend { /// Path to QEMU binary qemu_path: PathBuf, /// Runtime directory for VM state runtime_dir: PathBuf, } impl KvmBackend { /// Create a new KVM backend pub fn new(qemu_path: impl Into, runtime_dir: impl Into) -> Self { Self { qemu_path: qemu_path.into(), runtime_dir: runtime_dir.into(), } } /// Create with default paths pub fn with_defaults() -> Self { Self::new("/usr/bin/qemu-system-x86_64", resolve_runtime_dir()) } fn qmp_socket_path(&self, handle: &VmHandle) -> PathBuf { if let Some(path) = handle.backend_state.get("qmp_socket") { PathBuf::from(path) } else { PathBuf::from(&handle.runtime_dir).join("qmp.sock") } } } fn volume_format_name(format: VolumeFormat) -> &'static str { match format { VolumeFormat::Raw => "raw", VolumeFormat::Qcow2 => "qcow2", } } fn build_rbd_uri(pool: &str, image: &str, monitors: &[String], user: &str) -> String { let mut uri = format!("rbd:{pool}/{image}"); if !user.is_empty() { uri.push_str(&format!(":id={user}")); } if !monitors.is_empty() { uri.push_str(&format!(":mon_host={}", monitors.join(";"))); } uri } fn disk_source_arg(disk: &AttachedDisk) -> Result<(String, &'static str)> { match &disk.attachment { DiskAttachment::File { path, format } => Ok((path.clone(), volume_format_name(*format))), DiskAttachment::Nbd { uri, format } => Ok((uri.clone(), volume_format_name(*format))), DiskAttachment::CephRbd { pool, image, monitors, user, .. } => Ok((build_rbd_uri(pool, image, monitors, user), "raw")), } } fn effective_disk_cache(disk: &AttachedDisk) -> DiskCache { match (&disk.attachment, disk.cache) { // Shared NBD-backed volumes perform better and behave more predictably // with direct I/O than with host-side writeback caching. (DiskAttachment::Nbd { .. }, DiskCache::Writeback) => DiskCache::None, _ => disk.cache, } } fn disk_cache_mode(cache: DiskCache) -> &'static str { match cache { DiskCache::None => "none", DiskCache::Writeback => "writeback", DiskCache::Writethrough => "writethrough", } } fn disk_aio_mode(disk: &AttachedDisk) -> Option<&'static str> { match (&disk.attachment, disk.cache) { (DiskAttachment::File { .. }, DiskCache::None) => Some("native"), (DiskAttachment::File { .. }, _) => Some("threads"), (DiskAttachment::Nbd { .. }, _) => Some(resolve_nbd_aio_mode()), (DiskAttachment::CephRbd { .. }, _) => None, } } fn disk_uses_dedicated_iothread(disk: &AttachedDisk) -> bool { matches!( (&disk.attachment, disk.bus), (DiskAttachment::Nbd { .. }, DiskBus::Virtio) ) } fn disk_queue_count(vm: &VirtualMachine, disk: &AttachedDisk) -> u16 { if !disk_uses_dedicated_iothread(disk) { return 1; } vm.spec.cpu .vcpus .clamp(1, resolve_nbd_max_queues().max(1) as u32) as u16 } fn sanitize_device_component(value: &str, fallback_index: usize) -> String { let sanitized: String = value .chars() .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) .collect(); if sanitized.is_empty() { format!("disk-{fallback_index}") } else { sanitized } } fn bootindex_suffix(boot_index: Option) -> String { boot_index .filter(|index| *index > 0) .map(|index| format!(",bootindex={index}")) .unwrap_or_default() } fn qmp_timeout() -> Duration { Duration::from_secs(resolve_qmp_timeout_secs()) } fn build_disk_args(vm: &VirtualMachine, disks: &[AttachedDisk]) -> Result> { if disks.is_empty() && vm.spec.disks.is_empty() { let qcow_path = resolve_qcow2_path().ok_or_else(|| { Error::HypervisorError(format!( "{ENV_QCOW2_PATH} not set; provide qcow2 image to spawn VM" )) })?; if !qcow_path.exists() { return Err(Error::HypervisorError(format!( "Primary disk is not materialized at {}", qcow_path.display() ))); } return Ok(vec![ "-drive".into(), format!("file={},if=virtio,format=qcow2", qcow_path.display()), ]); } let mut args = Vec::new(); let has_scsi = vm .spec .disks .iter() .any(|disk| matches!(disk.bus, DiskBus::Scsi)); let has_ahci = vm .spec .disks .iter() .any(|disk| matches!(disk.bus, DiskBus::Ide | DiskBus::Sata)); if has_scsi { args.push("-device".into()); args.push("virtio-scsi-pci,id=scsi0".into()); } if has_ahci { args.push("-device".into()); args.push("ich9-ahci,id=ahci0".into()); } let mut disks: Vec<&AttachedDisk> = disks.iter().collect(); disks.sort_by(|lhs, rhs| { lhs.boot_index .unwrap_or(u32::MAX) .cmp(&rhs.boot_index.unwrap_or(u32::MAX)) .then_with(|| lhs.id.cmp(&rhs.id)) }); let mut scsi_slot = 0usize; let mut ahci_slot = 0usize; for (index, disk) in disks.into_iter().enumerate() { let disk_id = sanitize_device_component(&disk.id, index); let (source, format_name) = disk_source_arg(disk)?; if disk_uses_dedicated_iothread(disk) { args.push("-object".into()); args.push(format!("iothread,id=iothread-{disk_id}")); } let effective_cache = effective_disk_cache(disk); let mut drive_arg = format!( "file={source},if=none,format={format_name},id=drive-{disk_id},cache={}", disk_cache_mode(effective_cache) ); if let Some(aio_mode) = disk_aio_mode(disk) { drive_arg.push_str(&format!(",aio={aio_mode}")); } args.push("-drive".into()); args.push(drive_arg); let bootindex = bootindex_suffix(disk.boot_index); let device_arg = match disk.bus { DiskBus::Virtio => { let mut device_arg = format!("virtio-blk-pci,drive=drive-{disk_id},id=disk-{disk_id}"); if disk_uses_dedicated_iothread(disk) { let queues = disk_queue_count(vm, disk); device_arg.push_str(&format!( ",iothread=iothread-{disk_id},num-queues={queues},queue-size=1024" )); } device_arg.push_str(&bootindex); device_arg } DiskBus::Scsi => { let slot = scsi_slot; scsi_slot += 1; format!( "scsi-hd,drive=drive-{disk_id},id=disk-{disk_id},bus=scsi0.0,channel=0,scsi-id={slot},lun=0{bootindex}" ) } DiskBus::Ide | DiskBus::Sata => { if ahci_slot >= 6 { return Err(Error::HypervisorError( "Too many IDE/SATA disks for a single AHCI controller".into(), )); } let slot = ahci_slot; ahci_slot += 1; format!( "ide-hd,drive=drive-{disk_id},id=disk-{disk_id},bus=ahci0.{slot}{bootindex}" ) } }; args.push("-device".into()); args.push(device_arg); } Ok(args) } /// Build a minimal QEMU argument list for paused launch with QMP socket. fn build_qemu_args( vm: &VirtualMachine, disks: &[AttachedDisk], qmp_socket: &Path, console_log: &Path, kernel: Option<&Path>, initrd: Option<&Path>, ) -> Result> { let mut args = vec![ "-machine".into(), "q35,accel=kvm".into(), "-name".into(), vm.name.clone(), "-m".into(), vm.spec.memory.size_mib.to_string(), "-smp".into(), vm.spec.cpu.vcpus.to_string(), "-cpu".into(), vm.spec .cpu .cpu_model .clone() .unwrap_or_else(|| "host".into()), "-enable-kvm".into(), "-nographic".into(), "-display".into(), "none".into(), "-monitor".into(), "none".into(), "-qmp".into(), format!("unix:{},server=on,wait=off", qmp_socket.display()), "-serial".into(), format!("file:{}", console_log.display()), "-S".into(), ]; args.extend(build_disk_args(vm, disks)?); if let Some(kernel) = kernel { args.push("-kernel".into()); args.push(kernel.display().to_string()); if let Some(initrd) = initrd { args.push("-initrd".into()); args.push(initrd.display().to_string()); } args.push("-append".into()); args.push("console=ttyS0".into()); } Ok(args) } /// Build QEMU args for an incoming migration listener. fn build_qemu_args_incoming( vm: &VirtualMachine, disks: &[AttachedDisk], qmp_socket: &Path, console_log: &Path, kernel: Option<&Path>, initrd: Option<&Path>, listen_uri: &str, ) -> Result> { let mut args = build_qemu_args(vm, disks, qmp_socket, console_log, kernel, initrd)?; // Remove -S from the paused launch; incoming migration manages CPU start. if let Some(pos) = args.iter().position(|arg| arg == "-S") { args.remove(pos); } args.push("-incoming".into()); args.push(listen_uri.to_string()); Ok(args) } /// Wait for QMP socket to become available. async fn wait_for_qmp(qmp_socket: &Path, timeout: Duration) -> Result<()> { let start = Instant::now(); loop { match UnixStream::connect(qmp_socket).await { Ok(stream) => { drop(stream); return Ok(()); } Err(e) => { if start.elapsed() >= timeout { return Err(Error::HypervisorError(format!( "Timed out waiting for QMP socket {}: {e}", qmp_socket.display() ))); } tokio::time::sleep(Duration::from_millis(50)).await; } } } } fn kill_pid(pid: u32) -> Result<()> { let status = std::process::Command::new("kill") .arg("-9") .arg(pid.to_string()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map_err(|e| Error::HypervisorError(format!("Failed to invoke kill -9: {e}")))?; if status.success() { Ok(()) } else if !pid_running(pid) { Ok(()) } else { Err(Error::HypervisorError(format!( "kill -9 exited with status: {status}" ))) } } fn pid_running(pid: u32) -> bool { std::process::Command::new("kill") .arg("-0") .arg(pid.to_string()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|status| status.success()) .unwrap_or(false) } fn vm_stopped_out_of_band(handle: &VmHandle, qmp_socket: &Path) -> bool { if let Some(pid) = handle.pid { return !pid_running(pid); } !qmp_socket.exists() } fn stopped_status() -> VmStatus { VmStatus { actual_state: VmState::Stopped, ..VmStatus::default() } } #[async_trait] impl HypervisorBackend for KvmBackend { fn backend_type(&self) -> HypervisorType { HypervisorType::Kvm } fn capabilities(&self) -> BackendCapabilities { BackendCapabilities { live_migration: true, hot_plug_cpu: true, hot_plug_memory: true, hot_plug_disk: true, hot_plug_nic: true, vnc_console: true, serial_console: true, nested_virtualization: true, gpu_passthrough: true, max_vcpus: 256, max_memory_gib: 4096, supported_disk_buses: vec![DiskBus::Virtio, DiskBus::Scsi, DiskBus::Ide, DiskBus::Sata], supported_nic_models: vec![NicModel::VirtioNet, NicModel::E1000], } } fn supports(&self, _spec: &VmSpec) -> std::result::Result<(), UnsupportedReason> { // KVM supports all features, so no limitations Ok(()) } async fn create(&self, vm: &VirtualMachine, disks: &[AttachedDisk]) -> Result { tracing::info!( vm_id = %vm.id, name = %vm.name, "Creating VM (runtime prep + spawn)" ); let runtime_dir = self.runtime_dir.join(vm.id.to_string()); tokio::fs::create_dir_all(&runtime_dir) .await .map_err(|e| Error::HypervisorError(format!("Failed to create runtime dir: {e}")))?; let qmp_socket = runtime_dir.join("qmp.sock"); let console_log = runtime_dir.join("console.log"); // Remove stale socket if it exists from a previous run. let _ = tokio::fs::remove_file(&qmp_socket).await; let _ = tokio::fs::remove_file(&console_log).await; let qemu_bin = resolve_qemu_path(&self.qemu_path); let (kernel_path, initrd_path) = resolve_kernel_initrd(); let args = build_qemu_args( vm, disks, &qmp_socket, &console_log, kernel_path.as_deref(), initrd_path.as_deref(), )?; let mut cmd = Command::new(&qemu_bin); cmd.args(&args); tracing::debug!( vm_id = %vm.id, qemu_bin = %qemu_bin.display(), runtime_dir = %runtime_dir.display(), qmp_socket = %qmp_socket.display(), ?args, "Spawning KVM QEMU" ); let mut child = cmd .spawn() .map_err(|e| Error::HypervisorError(format!("Failed to spawn QEMU: {e}")))?; let pid = child.id().map(|p| p); // Wait for QMP readiness before detaching so slow nested workers do not leave orphans. if let Err(err) = wait_for_qmp(&qmp_socket, qmp_timeout()).await { tracing::warn!( vm_id = %vm.id, qmp_socket = %qmp_socket.display(), ?pid, error = %err, "QMP socket did not become ready; cleaning up spawned QEMU" ); let _ = child.start_kill(); let _ = child.wait().await; let _ = tokio::fs::remove_file(&qmp_socket).await; return Err(err); } // Detach process; lifecycle managed via QMP/kill later. tokio::spawn(async move { let _ = child.wait().await; }); let mut handle = VmHandle::new(vm.id, runtime_dir.to_string_lossy().to_string()); handle .backend_state .insert("qmp_socket".into(), qmp_socket.display().to_string()); handle .backend_state .insert("console_log".into(), console_log.display().to_string()); handle.pid = pid; handle.attached_disks = disks.to_vec(); Ok(handle) } async fn start(&self, handle: &VmHandle) -> Result<()> { let qmp_socket = self.qmp_socket_path(handle); wait_for_qmp(&qmp_socket, qmp_timeout()).await?; tracing::info!( vm_id = %handle.vm_id, qmp_socket = %qmp_socket.display(), "Starting VM via QMP cont" ); let mut client = QmpClient::connect(&qmp_socket).await?; client.command::("cont", None::).await?; Ok(()) } async fn stop(&self, handle: &VmHandle, timeout: Duration) -> Result<()> { let qmp_socket = self.qmp_socket_path(handle); if let Err(e) = wait_for_qmp(&qmp_socket, qmp_timeout()).await { if vm_stopped_out_of_band(handle, &qmp_socket) { tracing::info!(vm_id = %handle.vm_id, "VM already stopped before QMP stop"); return Ok(()); } if let Some(pid) = handle.pid { tracing::warn!(vm_id = %handle.vm_id, pid, "QMP unavailable; sending SIGKILL"); return kill_pid(pid); } return Err(e); } tracing::info!( vm_id = %handle.vm_id, timeout_secs = timeout.as_secs(), qmp_socket = %qmp_socket.display(), "Stopping VM via QMP system_powerdown" ); let mut client = QmpClient::connect(&qmp_socket).await?; if let Err(e) = client .command::("system_powerdown", None::) .await { if vm_stopped_out_of_band(handle, &qmp_socket) { tracing::info!( vm_id = %handle.vm_id, error = %e, "VM exited while handling system_powerdown; treating stop as successful" ); return Ok(()); } tracing::warn!( vm_id = %handle.vm_id, error = %e, "QMP powerdown command raced with shutdown; waiting for VM to stop" ); } let start = Instant::now(); loop { if vm_stopped_out_of_band(handle, &qmp_socket) { break; } match QmpClient::connect(&qmp_socket).await { Ok(mut client) => match client.query_status().await { Ok(status) if matches!(status.actual_state, VmState::Stopped | VmState::Failed) => { break; } Ok(_) => {} Err(e) if vm_stopped_out_of_band(handle, &qmp_socket) => break, Err(e) => { tracing::debug!( vm_id = %handle.vm_id, error = %e, "QMP query failed while waiting for shutdown" ); } }, Err(e) if vm_stopped_out_of_band(handle, &qmp_socket) => break, Err(e) => { tracing::debug!( vm_id = %handle.vm_id, error = %e, "QMP reconnect failed while waiting for shutdown" ); } } if start.elapsed() >= timeout { if let Some(pid) = handle.pid { tracing::warn!(vm_id = %handle.vm_id, pid, "Stop timed out; sending SIGKILL"); kill_pid(pid)?; break; } return Err(Error::HypervisorError(format!( "Timeout waiting for VM {} to stop", handle.vm_id ))); } tokio::time::sleep(Duration::from_millis(100)).await; } Ok(()) } async fn kill(&self, handle: &VmHandle) -> Result<()> { tracing::info!(vm_id = %handle.vm_id, "Force killing VM via QMP quit"); let qmp_socket = self.qmp_socket_path(handle); match wait_for_qmp(&qmp_socket, qmp_timeout()).await { Ok(_) => { let mut client = QmpClient::connect(&qmp_socket).await?; if let Err(e) = client.command::("quit", None::).await { tracing::warn!(vm_id = %handle.vm_id, error = %e, "QMP quit failed; attempting SIGKILL"); if let Some(pid) = handle.pid { return kill_pid(pid); } return Err(e); } } Err(e) => { if let Some(pid) = handle.pid { tracing::warn!(vm_id = %handle.vm_id, pid, "QMP unavailable; attempting SIGKILL"); return kill_pid(pid); } return Err(e); } } Ok(()) } async fn reboot(&self, handle: &VmHandle) -> Result<()> { tracing::info!(vm_id = %handle.vm_id, "Rebooting VM via QMP system_reset"); let qmp_socket = self.qmp_socket_path(handle); wait_for_qmp(&qmp_socket, qmp_timeout()).await?; let mut client = QmpClient::connect(&qmp_socket).await?; client .command::("system_reset", None::) .await?; Ok(()) } async fn prepare_incoming( &self, vm: &VirtualMachine, listen_uri: &str, disks: &[AttachedDisk], ) -> Result { tracing::info!( vm_id = %vm.id, listen_uri, "Preparing incoming migration listener" ); let runtime_dir = self.runtime_dir.join(vm.id.to_string()); tokio::fs::create_dir_all(&runtime_dir) .await .map_err(|e| Error::HypervisorError(format!("Failed to create runtime dir: {e}")))?; let qmp_socket = runtime_dir.join("qmp.sock"); let console_log = runtime_dir.join("console.log"); let _ = tokio::fs::remove_file(&qmp_socket).await; let _ = tokio::fs::remove_file(&console_log).await; let qemu_bin = resolve_qemu_path(&self.qemu_path); let (kernel_path, initrd_path) = resolve_kernel_initrd(); let args = build_qemu_args_incoming( vm, disks, &qmp_socket, &console_log, kernel_path.as_deref(), initrd_path.as_deref(), listen_uri, )?; let mut cmd = Command::new(&qemu_bin); cmd.args(&args); tracing::debug!( vm_id = %vm.id, qemu_bin = %qemu_bin.display(), runtime_dir = %runtime_dir.display(), qmp_socket = %qmp_socket.display(), ?args, "Spawning QEMU for incoming migration" ); let mut child = cmd .spawn() .map_err(|e| Error::HypervisorError(format!("Failed to spawn QEMU: {e}")))?; let pid = child.id().map(|p| p); if let Err(err) = wait_for_qmp(&qmp_socket, qmp_timeout()).await { tracing::warn!( vm_id = %vm.id, qmp_socket = %qmp_socket.display(), ?pid, error = %err, "Incoming migration QMP socket did not become ready; cleaning up spawned QEMU" ); let _ = child.start_kill(); let _ = child.wait().await; let _ = tokio::fs::remove_file(&qmp_socket).await; return Err(err); } tokio::spawn(async move { let _ = child.wait().await; }); let mut handle = VmHandle::new(vm.id, runtime_dir.to_string_lossy().to_string()); handle .backend_state .insert("qmp_socket".into(), qmp_socket.display().to_string()); handle .backend_state .insert("console_log".into(), console_log.display().to_string()); handle.pid = pid; handle.attached_disks = disks.to_vec(); Ok(handle) } async fn migrate( &self, handle: &VmHandle, destination_uri: &str, timeout: Duration, wait: bool, ) -> Result<()> { tracing::info!( vm_id = %handle.vm_id, destination_uri, wait, "Initiating live migration via QMP" ); let qmp_socket = self.qmp_socket_path(handle); wait_for_qmp(&qmp_socket, qmp_timeout()).await?; let mut client = QmpClient::connect(&qmp_socket).await?; client .command("migrate", Some(json!({ "uri": destination_uri }))) .await?; if !wait { return Ok(()); } let start = Instant::now(); loop { let resp = client .command::("query-migrate", None::) .await?; let status = resp .get("status") .and_then(Value::as_str) .unwrap_or("unknown"); match status { "completed" => return Ok(()), "failed" | "cancelled" => { let err = resp .get("error") .and_then(Value::as_str) .unwrap_or("migration failed"); return Err(Error::HypervisorError(format!("Migration failed: {err}"))); } _ => {} } if start.elapsed() >= timeout { return Err(Error::HypervisorError(format!( "Timeout waiting for migration of VM {}", handle.vm_id ))); } tokio::time::sleep(Duration::from_millis(200)).await; } } async fn delete(&self, handle: &VmHandle) -> Result<()> { tracing::info!(vm_id = %handle.vm_id, "Deleting VM resources"); if handle.pid.is_some() || self.qmp_socket_path(handle).exists() { let _ = self.kill(handle).await; } if let Some(pid) = handle.pid { let deadline = Instant::now() + Duration::from_secs(5); while pid_running(pid) { if Instant::now() >= deadline { return Err(Error::HypervisorError(format!( "Timed out waiting for VM {} process {} to exit", handle.vm_id, pid ))); } tokio::time::sleep(Duration::from_millis(100)).await; } } let runtime_dir = PathBuf::from(&handle.runtime_dir); if tokio::fs::try_exists(&runtime_dir) .await .map_err(|e| Error::HypervisorError(format!("Failed to inspect runtime dir: {e}")))? { tokio::fs::remove_dir_all(&runtime_dir).await.map_err(|e| { Error::HypervisorError(format!("Failed to remove runtime dir: {e}")) })?; } tracing::info!(vm_id = %handle.vm_id, "Deleted VM resources"); Ok(()) } async fn status(&self, handle: &VmHandle) -> Result { let qmp_socket = self.qmp_socket_path(handle); tracing::debug!( vm_id = %handle.vm_id, qmp_socket = %qmp_socket.display(), "Querying VM status via QMP" ); match QmpClient::connect(&qmp_socket).await { Ok(mut client) => match client.query_status().await { Ok(status) => Ok(status), Err(e) if vm_stopped_out_of_band(handle, &qmp_socket) => Ok(stopped_status()), Err(e) => Err(e), }, Err(e) if vm_stopped_out_of_band(handle, &qmp_socket) => Ok(stopped_status()), Err(e) => Err(e), } } async fn attach_disk(&self, handle: &VmHandle, disk: &AttachedDisk) -> Result<()> { tracing::info!( vm_id = %handle.vm_id, disk_id = %disk.id, "Attaching disk via QMP device_add" ); let qmp_socket = self.qmp_socket_path(handle); wait_for_qmp(&qmp_socket, qmp_timeout()).await?; let mut client = QmpClient::connect(&qmp_socket).await?; let blockdev_args = match &disk.attachment { DiskAttachment::File { path, format } => serde_json::json!({ "node-name": format!("drive-{}", disk.id), "driver": volume_format_name(*format), "read-only": disk.read_only, "file": { "driver": "file", "filename": path } }), DiskAttachment::Nbd { .. } => { return Err(Error::UnsupportedFeature( "KVM hot-plug for NBD-backed disks is not implemented".into(), )); } DiskAttachment::CephRbd { .. } => { return Err(Error::UnsupportedFeature( "KVM hot-plug for Ceph RBD-backed disks is not implemented".into(), )); } }; client.command("blockdev-add", Some(blockdev_args)).await?; // Step 2: Add virtio-blk-pci frontend device let device_args = serde_json::json!({ "driver": "virtio-blk-pci", "id": format!("disk-{}", disk.id), "drive": format!("drive-{}", disk.id) }); client.command("device_add", Some(device_args)).await?; tracing::info!( vm_id = %handle.vm_id, disk_id = %disk.id, "Disk attached successfully" ); Ok(()) } async fn detach_disk(&self, handle: &VmHandle, disk_id: &str) -> Result<()> { tracing::info!( vm_id = %handle.vm_id, disk_id = disk_id, "Detaching disk via QMP device_del" ); let qmp_socket = self.qmp_socket_path(handle); wait_for_qmp(&qmp_socket, qmp_timeout()).await?; let mut client = QmpClient::connect(&qmp_socket).await?; // Remove the virtio-blk-pci device (backend will be cleaned up automatically) let device_args = serde_json::json!({ "id": format!("disk-{}", disk_id) }); client.command("device_del", Some(device_args)).await?; tracing::info!( vm_id = %handle.vm_id, disk_id = disk_id, "Disk detached successfully" ); Ok(()) } async fn attach_nic(&self, handle: &VmHandle, nic: &NetworkSpec) -> Result<()> { tracing::info!( vm_id = %handle.vm_id, nic_id = %nic.id, "Attaching NIC via QMP device_add" ); let qmp_socket = self.qmp_socket_path(handle); wait_for_qmp(&qmp_socket, qmp_timeout()).await?; let mut client = QmpClient::connect(&qmp_socket).await?; // Generate MAC address if not provided let mac_addr = nic .mac_address .as_ref() .map(|s| s.as_str()) .unwrap_or_else(|| { // Generate a simple MAC (should be more sophisticated in production) "52:54:00:12:34:56" }); // Step 1: Add network backend via netdev_add let netdev_args = serde_json::json!({ "type": "tap", "id": format!("netdev-{}", nic.id), "ifname": format!("tap-{}", nic.id), "script": "no", "downscript": "no" }); client.command("netdev_add", Some(netdev_args)).await?; // Step 2: Add virtio-net-pci frontend device let device_args = serde_json::json!({ "driver": "virtio-net-pci", "id": format!("net-{}", nic.id), "netdev": format!("netdev-{}", nic.id), "mac": mac_addr }); client.command("device_add", Some(device_args)).await?; tracing::info!( vm_id = %handle.vm_id, nic_id = %nic.id, mac = mac_addr, "NIC attached successfully" ); Ok(()) } async fn detach_nic(&self, handle: &VmHandle, nic_id: &str) -> Result<()> { tracing::info!( vm_id = %handle.vm_id, nic_id = nic_id, "Detaching NIC via QMP device_del" ); let qmp_socket = self.qmp_socket_path(handle); wait_for_qmp(&qmp_socket, qmp_timeout()).await?; let mut client = QmpClient::connect(&qmp_socket).await?; // Remove the virtio-net-pci device (netdev backend will be cleaned up automatically) let device_args = serde_json::json!({ "id": format!("net-{}", nic_id) }); client.command("device_del", Some(device_args)).await?; tracing::info!( vm_id = %handle.vm_id, nic_id = nic_id, "NIC detached successfully" ); Ok(()) } } #[cfg(test)] mod tests { use super::*; use plasmavmc_types::DiskSpec; use tokio::net::UnixListener; #[test] fn test_kvm_backend_creation() { let backend = KvmBackend::with_defaults(); assert_eq!(backend.backend_type(), HypervisorType::Kvm); } #[test] fn test_kvm_capabilities() { let backend = KvmBackend::with_defaults(); let caps = backend.capabilities(); assert!(caps.live_migration); assert!(caps.vnc_console); assert!(caps.serial_console); assert_eq!(caps.max_vcpus, 256); } #[test] fn test_kvm_supports_all_specs() { let backend = KvmBackend::with_defaults(); let spec = VmSpec::default(); assert!(backend.supports(&spec).is_ok()); } #[test] fn build_qemu_args_contains_qmp_and_memory() { let _guard = crate::env::env_test_lock().lock().unwrap(); let vm = VirtualMachine::new("vm1", "org", "proj", VmSpec::default()); let qmp = PathBuf::from("/tmp/qmp.sock"); let temp = tempfile::tempdir().unwrap(); let qcow = temp.path().join("image.qcow2"); std::fs::write(&qcow, b"image").unwrap(); std::env::set_var(env::ENV_QCOW2_PATH, &qcow); let console = PathBuf::from("/tmp/console.log"); let args = build_qemu_args(&vm, &[], &qmp, &console, None, None).unwrap(); let args_joined = args.join(" "); assert!(args_joined.contains("qmp.sock")); assert!(args_joined.contains("512")); // default memory MiB assert!(args_joined.contains("image.qcow2")); assert!(args_joined.contains("console.log")); std::env::remove_var(env::ENV_QCOW2_PATH); } #[test] fn build_qemu_args_includes_all_materialized_disks() { let _guard = crate::env::env_test_lock().lock().unwrap(); let temp = tempfile::tempdir().unwrap(); let volume_dir = temp.path().join("volumes"); std::fs::create_dir_all(&volume_dir).unwrap(); std::fs::write(volume_dir.join("vm-root.qcow2"), b"root").unwrap(); std::fs::write(volume_dir.join("vm-data.qcow2"), b"data").unwrap(); let mut spec = VmSpec::default(); spec.disks = vec![ DiskSpec { id: "root".into(), source: plasmavmc_types::DiskSource::Volume { volume_id: "vm-root".into(), }, size_gib: 4, bus: DiskBus::Virtio, cache: DiskCache::None, boot_index: Some(1), }, DiskSpec { id: "data".into(), source: plasmavmc_types::DiskSource::Volume { volume_id: "vm-data".into(), }, size_gib: 2, bus: DiskBus::Virtio, cache: DiskCache::Writeback, boot_index: None, }, ]; let vm = VirtualMachine::new("vm1", "org", "proj", spec); let disks = vec![ AttachedDisk { id: "root".into(), attachment: DiskAttachment::File { path: volume_dir.join("vm-root.qcow2").display().to_string(), format: VolumeFormat::Qcow2, }, bus: DiskBus::Virtio, cache: DiskCache::None, boot_index: Some(1), read_only: false, }, AttachedDisk { id: "data".into(), attachment: DiskAttachment::File { path: volume_dir.join("vm-data.qcow2").display().to_string(), format: VolumeFormat::Qcow2, }, bus: DiskBus::Virtio, cache: DiskCache::Writeback, boot_index: None, read_only: false, }, ]; let qmp = PathBuf::from("/tmp/qmp.sock"); let console = PathBuf::from("/tmp/console.log"); let args = build_qemu_args(&vm, &disks, &qmp, &console, None, None).unwrap(); let args_joined = args.join(" "); assert!(args_joined.contains("vm-root.qcow2")); assert!(args_joined.contains("vm-data.qcow2")); assert!(args_joined.contains("bootindex=1")); assert!(args_joined.contains("cache=writeback")); assert!(args_joined.contains("cache=none,aio=native")); assert!(args_joined.contains("cache=writeback,aio=threads")); } #[test] fn build_qemu_args_assigns_iothread_to_nbd_virtio_disks() { let mut spec = VmSpec::default(); spec.cpu.vcpus = 4; let vm = VirtualMachine::new("vm1", "org", "proj", spec); let disks = vec![AttachedDisk { id: "root".into(), attachment: DiskAttachment::Nbd { uri: "nbd://10.100.0.11:11000".into(), format: VolumeFormat::Raw, }, bus: DiskBus::Virtio, cache: DiskCache::None, boot_index: Some(1), read_only: false, }]; let qmp = PathBuf::from("/tmp/qmp.sock"); let console = PathBuf::from("/tmp/console.log"); let args = build_qemu_args(&vm, &disks, &qmp, &console, None, None).unwrap(); let args_joined = args.join(" "); assert!(args_joined.contains("-object iothread,id=iothread-root")); assert!(args_joined.contains("virtio-blk-pci,drive=drive-root,id=disk-root,iothread=iothread-root,num-queues=4,queue-size=1024,bootindex=1")); } #[test] fn build_qemu_args_coerces_writeback_cache_to_none_for_nbd_disks() { let _guard = crate::env::env_test_lock().lock().unwrap(); std::env::remove_var(crate::env::ENV_NBD_AIO_MODE); let vm = VirtualMachine::new("vm1", "org", "proj", VmSpec::default()); let disks = vec![AttachedDisk { id: "root".into(), attachment: DiskAttachment::Nbd { uri: "nbd://10.100.0.11:11000".into(), format: VolumeFormat::Raw, }, bus: DiskBus::Virtio, cache: DiskCache::Writeback, boot_index: Some(1), read_only: false, }]; let qmp = PathBuf::from("/tmp/qmp.sock"); let console = PathBuf::from("/tmp/console.log"); let args = build_qemu_args(&vm, &disks, &qmp, &console, None, None).unwrap(); let args_joined = args.join(" "); assert!(args_joined.contains("cache=none,aio=io_uring")); } #[test] fn build_qemu_args_uses_io_uring_for_nbd_none_cache_by_default() { let _guard = crate::env::env_test_lock().lock().unwrap(); std::env::remove_var(crate::env::ENV_NBD_AIO_MODE); let vm = VirtualMachine::new("vm1", "org", "proj", VmSpec::default()); let disks = vec![AttachedDisk { id: "root".into(), attachment: DiskAttachment::Nbd { uri: "nbd://10.100.0.11:11000".into(), format: VolumeFormat::Raw, }, bus: DiskBus::Virtio, cache: DiskCache::None, boot_index: Some(1), read_only: false, }]; let qmp = PathBuf::from("/tmp/qmp.sock"); let console = PathBuf::from("/tmp/console.log"); let args = build_qemu_args(&vm, &disks, &qmp, &console, None, None).unwrap(); let args_joined = args.join(" "); assert!(args_joined.contains("cache=none,aio=io_uring")); } #[test] fn build_qemu_args_honors_nbd_aio_override() { let _guard = crate::env::env_test_lock().lock().unwrap(); std::env::set_var(crate::env::ENV_NBD_AIO_MODE, "threads"); let vm = VirtualMachine::new("vm1", "org", "proj", VmSpec::default()); let disks = vec![AttachedDisk { id: "root".into(), attachment: DiskAttachment::Nbd { uri: "nbd://10.100.0.11:11000".into(), format: VolumeFormat::Raw, }, bus: DiskBus::Virtio, cache: DiskCache::None, boot_index: Some(1), read_only: false, }]; let qmp = PathBuf::from("/tmp/qmp.sock"); let console = PathBuf::from("/tmp/console.log"); let args = build_qemu_args(&vm, &disks, &qmp, &console, None, None).unwrap(); let args_joined = args.join(" "); assert!(args_joined.contains("cache=none,aio=threads")); std::env::remove_var(crate::env::ENV_NBD_AIO_MODE); } #[tokio::test] async fn wait_for_qmp_succeeds_after_socket_created() { let dir = tempfile::tempdir().unwrap(); let socket_path = dir.path().join("qmp.sock"); let socket_clone = socket_path.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(100)).await; let _listener = UnixListener::bind(socket_clone).expect("bind socket"); // Keep listener alive briefly tokio::time::sleep(Duration::from_millis(200)).await; }); wait_for_qmp(&socket_path, Duration::from_secs(1)) .await .expect("qmp became ready"); } // Integration smoke: requires env to point to QEMU and a qcow2 image. #[tokio::test] #[ignore] async fn integration_create_start_status_stop() { let _guard = crate::env::env_test_lock().lock().unwrap(); let qemu = std::env::var(env::ENV_QEMU_PATH) .unwrap_or_else(|_| "/usr/bin/qemu-system-x86_64".into()); let qcow = match std::env::var(env::ENV_QCOW2_PATH) { Ok(path) => path, Err(_) => { eprintln!("Skipping integration: {} not set", env::ENV_QCOW2_PATH); return; } }; if !Path::new(&qemu).exists() || !Path::new(&qcow).exists() { eprintln!("Skipping integration: qemu or qcow2 path missing"); return; } let backend = KvmBackend::new(qemu, tempfile::tempdir().unwrap().into_path()); let vm = VirtualMachine::new("int", "org", "proj", VmSpec::default()); let handle = backend.create(&vm, &[]).await.expect("create vm"); backend.start(&handle).await.expect("start vm"); let status = backend.status(&handle).await.expect("status vm"); assert!( matches!( status.actual_state, VmState::Running | VmState::Stopped | VmState::Error ), "unexpected state: {:?}", status.actual_state ); backend .stop(&handle, Duration::from_secs(2)) .await .expect("stop vm"); } }