use crate::host::{ FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostDiskInfo, VmHostInfo, }; use crate::settings::QemuConfig; use crate::status::{VmRunningState, VmState}; use crate::KB; use anyhow::{bail, ensure, Context, Result}; use chrono::Utc; use lnvps_db::{async_trait, LNVpsDb, Vm, VmOsImage}; use log::info; use rand::random; use serde::{Deserialize, Serialize}; use std::str::FromStr; use std::sync::Arc; use uuid::Uuid; use virt::connect::Connect; use virt::domain::Domain; use virt::sys::{ virDomainCreate, VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE, VIR_DOMAIN_START_VALIDATE, }; #[derive(Debug)] pub struct LibVirtHost { connection: Connect, qemu: QemuConfig, } impl LibVirtHost { pub fn new(url: &str, qemu: QemuConfig) -> Result { Ok(Self { connection: Connect::open(Some(url))?, qemu, }) } pub fn import_disk_image(&self, vm: &Vm, image: &VmOsImage) -> Result<()> { // https://libvirt.org/html/libvirt-libvirt-storage.html#virStorageVolUpload // https://libvirt.org/html/libvirt-libvirt-storage.html#virStorageVolResize Ok(()) } pub fn create_domain_xml(&self, cfg: &FullVmInfo) -> Result { let storage = self .connection .list_all_storage_pools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE)?; // check the storage disk exists, we don't need anything else from it for now let _storage_disk = if let Some(d) = storage .iter() .find(|s| s.get_name().map(|n| n == cfg.disk.name).unwrap_or(false)) { d } else { bail!( "Disk \"{}\" not found on host! Available pools: {}", cfg.disk.name, storage .iter() .filter_map(|s| s.get_name().ok()) .collect::>() .join(",") ); }; let resources = cfg.resources()?; let mut devices = vec![]; // primary disk devices.push(DomainDevice::Disk(Disk { kind: DiskType::File, device: DiskDevice::Disk, source: DiskSource { file: Some(format!("{}:vm-{}-disk0", cfg.disk.name, cfg.vm.id)), ..Default::default() }, target: DiskTarget { dev: "vda".to_string(), bus: Some(DiskBus::VirtIO), }, })); devices.push(DomainDevice::Interface(NetworkInterface { kind: NetworkKind::Bridge, mac: Some(NetworkMac { address: cfg.vm.mac_address.clone(), }), source: Some(NetworkSource { bridge: Some(self.qemu.bridge.clone()), }), target: None, vlan: cfg.host.vlan_id.map(|v| NetworkVlan { tags: vec![NetworkVlanTag { id: v as u32 }], }), })); Ok(DomainXML { kind: DomainType::KVM, id: Some(cfg.vm.id), name: Some(format!("VM{}", cfg.vm.id)), uuid: None, title: None, description: None, os: DomainOs { kind: DomainOsType { kind: DomainOsTypeKind::Hvm, arch: Some(DomainOsArch::from_str(&self.qemu.arch)?), machine: Some(DomainOsMachine::from_str(&self.qemu.machine)?), }, firmware: Some(DomainOsFirmware::EFI), loader: Some(DomainOsLoader { read_only: None, kind: None, secure: Some(true), stateless: None, format: None, }), boot: DomainOsBoot { dev: DomainOsBootDev::HardDrive, }, }, vcpu: resources.cpu, memory: resources.memory, devices: DomainDevices { contents: devices }, }) } } #[async_trait] impl VmHostClient for LibVirtHost { async fn get_info(&self) -> Result { let info = self.connection.get_node_info()?; let storage = self .connection .list_all_storage_pools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE)?; Ok(VmHostInfo { cpu: info.cpus as u16, memory: info.memory * KB, disks: storage .iter() .filter_map(|p| { let info = p.get_info().ok()?; Some(VmHostDiskInfo { name: p.get_name().context("storage pool name is missing").ok()?, size: info.capacity, used: info.allocation, }) }) .collect(), }) } async fn download_os_image(&self, image: &VmOsImage) -> Result<()> { // TODO: download ISO images to host (somehow, ssh?) Ok(()) } async fn generate_mac(&self, _vm: &Vm) -> Result { Ok(format!( "52:54:00:{}:{}:{}", hex::encode([random::()]), hex::encode([random::()]), hex::encode([random::()]) )) } async fn start_vm(&self, vm: &Vm) -> Result<()> { Ok(()) } async fn stop_vm(&self, vm: &Vm) -> Result<()> { Ok(()) } async fn reset_vm(&self, vm: &Vm) -> Result<()> { Ok(()) } async fn create_vm(&self, cfg: &FullVmInfo) -> Result<()> { let domain = self.create_domain_xml(cfg)?; let xml = quick_xml::se::to_string(&domain)?; let domain = Domain::create_xml(&self.connection, &xml, VIR_DOMAIN_START_VALIDATE)?; Ok(()) } async fn delete_vm(&self, vm: &Vm) -> Result<()> { todo!() } async fn reinstall_vm(&self, cfg: &FullVmInfo) -> Result<()> { todo!() } async fn get_vm_state(&self, vm: &Vm) -> Result { Ok(VmState { timestamp: Utc::now().timestamp() as u64, state: VmRunningState::Stopped, cpu_usage: 0.0, mem_usage: 0.0, uptime: 0, net_in: 0, net_out: 0, disk_write: 0, disk_read: 0, }) } async fn configure_vm(&self, vm: &FullVmInfo) -> Result<()> { todo!() } async fn get_time_series_data( &self, vm: &Vm, series: TimeSeries, ) -> Result> { todo!() } async fn connect_terminal(&self, vm: &Vm) -> Result { todo!() } } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename = "domain")] struct DomainXML { #[serde(rename = "@type")] pub kind: DomainType, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@id")] pub id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub uuid: Option, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub os: DomainOs, pub vcpu: u16, pub memory: u64, pub devices: DomainDevices, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename = "devices")] struct DomainDevices { #[serde(rename = "$value")] pub contents: Vec, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum DomainType { #[default] KVM, XEN, HVF, QEMU, LXC, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename = "os")] struct DomainOs { #[serde(rename = "type")] pub kind: DomainOsType, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@firmware")] pub firmware: Option, #[serde(skip_serializing_if = "Option::is_none")] pub loader: Option, pub boot: DomainOsBoot, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum DomainOsFirmware { #[default] EFI, BIOS, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] struct DomainOsType { #[serde(rename = "$text")] pub kind: DomainOsTypeKind, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@arch")] pub arch: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@machine")] pub machine: Option, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum DomainOsTypeKind { #[default] Hvm, Xen, Linux, XenPvh, Exe, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum DomainOsMachine { #[default] Q35, PC, } impl FromStr for DomainOsMachine { type Err = anyhow::Error; fn from_str(s: &str) -> std::result::Result { match s.to_lowercase().as_str() { "q35" => Ok(DomainOsMachine::Q35), "pc" => Ok(DomainOsMachine::PC), v => bail!("Unknown machine type {}", v), } } } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum DomainOsArch { #[default] X86_64, I686, } impl FromStr for DomainOsArch { type Err = anyhow::Error; fn from_str(s: &str) -> std::result::Result { match s.to_lowercase().as_str() { "x86_64" => Ok(Self::X86_64), "i686" => Ok(Self::I686), v => bail!("unsupported arch {}", v), } } } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename = "loader")] struct DomainOsLoader { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@readonly")] pub read_only: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@type")] pub kind: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@secure")] pub secure: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@stateless")] pub stateless: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@format")] pub format: Option, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum DomainOsLoaderType { #[default] ROM, PFlash, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum DomainOsLoaderFormat { Raw, #[default] QCow2, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] struct DomainOsBoot { #[serde(rename = "@dev")] pub dev: DomainOsBootDev, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum DomainOsBootDev { #[serde(rename = "fd")] Floppy, #[serde(rename = "hd")] #[default] HardDrive, CdRom, Network, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename = "vcpu")] struct DomainVCPU { #[serde(rename = "$text")] pub count: u32, } #[derive(Debug, Serialize, Deserialize, Clone)] enum DomainDevice { #[serde(rename = "disk")] Disk(Disk), #[serde(rename = "interface")] Interface(NetworkInterface), #[serde(other)] Other, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename = "interface")] struct NetworkInterface { #[serde(rename = "@type")] pub kind: NetworkKind, #[serde(skip_serializing_if = "Option::is_none")] pub mac: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, #[serde(skip_serializing_if = "Option::is_none")] pub vlan: Option, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename = "vlan")] struct NetworkVlan { #[serde(rename = "tag")] pub tags: Vec, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename = "tag")] struct NetworkVlanTag { #[serde(rename = "@id")] pub id: u32, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum NetworkKind { Network, #[default] Bridge, User, Ethernet, Direct, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename = "mac")] struct NetworkMac { #[serde(rename = "@address")] pub address: String, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename = "source")] struct NetworkSource { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@bridge")] pub bridge: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename = "target")] struct NetworkTarget { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@dev")] pub dev: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename = "disk")] struct Disk { #[serde(rename = "@type")] pub kind: DiskType, #[serde(rename = "@device")] pub device: DiskDevice, pub source: DiskSource, pub target: DiskTarget, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum DiskType { #[default] File, Block, Dir, Network, Volume, Nvme, VHostUser, VHostVdpa, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum DiskDevice { Floppy, #[default] Disk, CdRom, Lun, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename = "source")] struct DiskSource { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@file")] pub file: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@dir")] pub dir: Option, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename = "target")] struct DiskTarget { /// Device name (hint) #[serde(rename = "@dev")] pub dev: String, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "@bus")] pub bus: Option, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "lowercase")] enum DiskBus { #[default] IDE, SCSI, VirtIO, XEN, USB, SATA, } #[cfg(test)] mod tests { use super::*; use crate::host::tests::mock_full_vm; fn cfg() -> FullVmInfo { let mut cfg = mock_full_vm(); // adjust mock data for libvirt test driver cfg.disk.name = "default-pool".to_string(); cfg } #[test] fn test_xml_os() -> Result<()> { let tag = "hvm"; let test = DomainOs { kind: DomainOsType { kind: DomainOsTypeKind::Hvm, arch: None, machine: None, }, firmware: Some(DomainOsFirmware::EFI), loader: None, boot: DomainOsBoot { dev: DomainOsBootDev::HardDrive, }, }; let xml = quick_xml::se::to_string(&test)?; assert_eq!(tag, xml); Ok(()) } #[test] fn text_xml_disk() -> Result<()> { let tag = ""; let test = Disk { kind: DiskType::File, device: DiskDevice::Disk, source: DiskSource { file: Some("/var/lib/libvirt/images/disk.qcow2".to_string()), ..Default::default() }, target: DiskTarget { dev: "vda".to_string(), bus: Some(DiskBus::VirtIO), }, }; let xml = quick_xml::se::to_string(&test)?; assert_eq!(tag, xml); Ok(()) } #[test] fn text_config_to_domain() -> Result<()> { let cfg = cfg(); let template = cfg.template.clone().unwrap(); let q_cfg = QemuConfig { machine: "q35".to_string(), os_type: "l26".to_string(), bridge: "vmbr0".to_string(), cpu: "kvm64".to_string(), kvm: true, arch: "x86_64".to_string(), }; let host = LibVirtHost::new("test:///default", q_cfg)?; let xml = host.create_domain_xml(&cfg)?; let res = cfg.resources()?; assert_eq!(xml.vcpu, res.cpu); assert_eq!(xml.memory, res.memory); let xml = quick_xml::se::to_string(&xml)?; println!("{}", xml); let output = r#"VM1hvm22147483648"#; assert_eq!(xml, output); Ok(()) } #[ignore] #[tokio::test] async fn text_vm_lifecycle() -> Result<()> { let cfg = cfg(); let template = cfg.template.clone().unwrap(); let q_cfg = QemuConfig { machine: "q35".to_string(), os_type: "l26".to_string(), bridge: "vmbr0".to_string(), cpu: "kvm64".to_string(), kvm: true, arch: "x86_64".to_string(), }; let host = LibVirtHost::new("test:///default", q_cfg)?; println!("{:?}", host.get_info().await?); host.create_vm(&cfg).await?; Ok(()) } }