Files
api/src/host/proxmox.rs
2025-03-04 11:03:15 +00:00

909 lines
26 KiB
Rust

use crate::host::{FullVmInfo, VmHostClient};
use crate::json_api::JsonApi;
use crate::settings::{QemuConfig, SshConfig};
use crate::ssh_client::SshClient;
use crate::status::{VmRunningState, VmState};
use anyhow::{anyhow, bail, ensure, Result};
use chrono::Utc;
use futures::future::join_all;
use ipnetwork::IpNetwork;
use lnvps_db::{async_trait, DiskType, IpRange, LNVpsDb, Vm, VmIpAssignment, VmOsImage};
use log::{info, warn};
use rand::random;
use reqwest::header::{HeaderMap, AUTHORIZATION};
use reqwest::{ClientBuilder, Method, Url};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::HashMap;
use std::fmt::{Debug, Display, Formatter};
use std::net::IpAddr;
use std::str::FromStr;
use std::time::Duration;
use tokio::time::sleep;
pub struct ProxmoxClient {
api: JsonApi,
config: QemuConfig,
ssh: Option<SshConfig>,
mac_prefix: String,
node: String,
}
impl ProxmoxClient {
pub fn new(
base: Url,
node: &str,
token: &str,
mac_prefix: Option<String>,
config: QemuConfig,
ssh: Option<SshConfig>,
) -> Self {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
format!("PVEAPIToken={}", token).parse().unwrap(),
);
let client = ClientBuilder::new()
.danger_accept_invalid_certs(true)
.default_headers(headers)
.build()
.expect("Failed to build client");
Self {
api: JsonApi { base, client },
config,
ssh,
node: node.to_string(),
mac_prefix: mac_prefix.unwrap_or("bc:24:11".to_string()),
}
}
/// Get version info
pub async fn version(&self) -> Result<VersionResponse> {
let rsp: ResponseBase<VersionResponse> = self.api.get("/api2/json/version").await?;
Ok(rsp.data)
}
/// List nodes
pub async fn list_nodes(&self) -> Result<Vec<NodeResponse>> {
let rsp: ResponseBase<Vec<NodeResponse>> = self.api.get("/api2/json/nodes").await?;
Ok(rsp.data)
}
pub async fn get_vm_status(&self, node: &str, vm_id: ProxmoxVmId) -> Result<VmInfo> {
let rsp: ResponseBase<VmInfo> = self
.api
.get(&format!(
"/api2/json/nodes/{node}/qemu/{vm_id}/status/current"
))
.await?;
Ok(rsp.data)
}
pub async fn list_vms(&self, node: &str) -> Result<Vec<VmInfo>> {
let rsp: ResponseBase<Vec<VmInfo>> = self
.api
.get(&format!("/api2/json/nodes/{node}/qemu"))
.await?;
Ok(rsp.data)
}
pub async fn list_storage(&self, node: &str) -> Result<Vec<NodeStorage>> {
let rsp: ResponseBase<Vec<NodeStorage>> = self
.api
.get(&format!("/api2/json/nodes/{node}/storage"))
.await?;
Ok(rsp.data)
}
/// List files in a storage pool
pub async fn list_storage_files(
&self,
node: &str,
storage: &str,
) -> Result<Vec<StorageContentEntry>> {
let rsp: ResponseBase<Vec<StorageContentEntry>> = self
.api
.get(&format!(
"/api2/json/nodes/{node}/storage/{storage}/content"
))
.await?;
Ok(rsp.data)
}
/// Create a new VM
///
/// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/qemu
pub async fn create_vm(&self, req: CreateVm) -> Result<TaskId> {
let rsp: ResponseBase<Option<String>> = self
.api
.post(&format!("/api2/json/nodes/{}/qemu", req.node), &req)
.await?;
if let Some(id) = rsp.data {
Ok(TaskId { id, node: req.node })
} else {
Err(anyhow!("Failed to configure VM"))
}
}
/// Configure a VM
///
/// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/qemu/{vmid}/config
pub async fn configure_vm(&self, req: ConfigureVm) -> Result<TaskId> {
let rsp: ResponseBase<Option<String>> = self
.api
.post(
&format!("/api2/json/nodes/{}/qemu/{}/config", req.node, req.vm_id),
&req,
)
.await?;
if let Some(id) = rsp.data {
Ok(TaskId { id, node: req.node })
} else {
Err(anyhow!("Failed to configure VM"))
}
}
/// Delete VM
///
/// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/qemu
pub async fn delete_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> {
let rsp: ResponseBase<Option<String>> = self
.api
.req(
Method::DELETE,
&format!("/api2/json/nodes/{node}/qemu/{vm}"),
(),
)
.await?;
if let Some(id) = rsp.data {
Ok(TaskId {
id,
node: node.to_string(),
})
} else {
Err(anyhow!("Failed to configure VM"))
}
}
/// Get the current status of a running task
///
/// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/tasks/{upid}/status
pub async fn get_task_status(&self, task: &TaskId) -> Result<TaskStatus> {
let rsp: ResponseBase<TaskStatus> = self
.api
.get(&format!(
"/api2/json/nodes/{}/tasks/{}/status",
task.node, task.id
))
.await?;
Ok(rsp.data)
}
/// Helper function to wait for a task to complete
pub async fn wait_for_task(&self, task: &TaskId) -> Result<TaskStatus> {
loop {
let s = self.get_task_status(task).await?;
if s.is_finished() {
if s.is_success() {
return Ok(s);
} else {
bail!(
"Task finished with error: {}",
s.exit_status.unwrap_or("no error message".to_string())
);
}
}
sleep(Duration::from_secs(1)).await;
}
}
async fn get_iso_storage(&self, node: &str) -> Result<String> {
let storages = self.list_storage(node).await?;
if let Some(s) = storages
.iter()
.find(|s| s.contents().contains(&StorageContent::ISO))
{
Ok(s.storage.clone())
} else {
bail!("No image storage found");
}
}
/// Download an image to the host disk
pub async fn download_image(&self, req: DownloadUrlRequest) -> Result<TaskId> {
let rsp: ResponseBase<String> = self
.api
.post(
&format!(
"/api2/json/nodes/{}/storage/{}/download-url",
req.node, req.storage
),
&req,
)
.await?;
Ok(TaskId {
id: rsp.data,
node: req.node,
})
}
pub async fn import_disk_image(&self, req: ImportDiskImageRequest) -> Result<()> {
// import the disk
// TODO: find a way to avoid using SSH
if let Some(ssh_config) = &self.ssh {
let mut ses = SshClient::new()?;
ses.connect(
(self.api.base.host().unwrap().to_string(), 22),
&ssh_config.user,
&ssh_config.key,
)
.await?;
// Disk import args
let mut disk_args: HashMap<&str, String> = HashMap::new();
disk_args.insert(
"import-from",
format!("/var/lib/vz/template/iso/{}", req.image),
);
// If disk is SSD, enable discard + ssd options
if req.is_ssd {
disk_args.insert("discard", "on".to_string());
disk_args.insert("ssd", "1".to_string());
}
let cmd = format!(
"/usr/sbin/qm set {} --{} {}:0,{}",
req.vm_id,
&req.disk,
&req.storage,
disk_args
.into_iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(",")
);
let (code, rsp) = ses.execute(cmd.as_str()).await?;
info!("{}", rsp);
if code != 0 {
bail!("Failed to import disk, exit-code {}, {}", code, rsp);
}
Ok(())
} else {
bail!("Cannot complete, no method available to import disk, consider configuring ssh")
}
}
/// Resize a disk on a VM
pub async fn resize_disk(&self, req: ResizeDiskRequest) -> Result<TaskId> {
let rsp: ResponseBase<String> = self
.api
.req(
Method::PUT,
&format!("/api2/json/nodes/{}/qemu/{}/resize", &req.node, &req.vm_id),
&req,
)
.await?;
Ok(TaskId {
id: rsp.data,
node: req.node,
})
}
/// Start a VM
pub async fn start_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> {
let rsp: ResponseBase<String> = self
.api
.post(
&format!("/api2/json/nodes/{}/qemu/{}/status/start", node, vm),
(),
)
.await?;
Ok(TaskId {
id: rsp.data,
node: node.to_string(),
})
}
/// Stop a VM
pub async fn stop_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> {
let rsp: ResponseBase<String> = self
.api
.post(
&format!("/api2/json/nodes/{}/qemu/{}/status/stop", node, vm),
(),
)
.await?;
Ok(TaskId {
id: rsp.data,
node: node.to_string(),
})
}
/// Stop a VM
pub async fn shutdown_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> {
let rsp: ResponseBase<String> = self
.api
.post(
&format!("/api2/json/nodes/{}/qemu/{}/status/shutdown", node, vm),
(),
)
.await?;
Ok(TaskId {
id: rsp.data,
node: node.to_string(),
})
}
/// Stop a VM
pub async fn reset_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> {
let rsp: ResponseBase<String> = self
.api
.post(
&format!("/api2/json/nodes/{}/qemu/{}/status/reset", node, vm),
(),
)
.await?;
Ok(TaskId {
id: rsp.data,
node: node.to_string(),
})
}
}
impl ProxmoxClient {
fn make_config(&self, value: &FullVmInfo) -> Result<VmConfig> {
let mut ip_config = value
.ips
.iter()
.map_while(|ip| {
if let Ok(net) = ip.ip.parse::<IpAddr>() {
Some(match net {
IpAddr::V4(addr) => {
let range = value.ranges.iter().find(|r| r.id == ip.ip_range_id)?;
let range: IpNetwork = range.gateway.parse().ok()?;
format!(
"ip={},gw={}",
IpNetwork::new(addr.into(), range.prefix()).ok()?,
range.ip()
)
}
IpAddr::V6(addr) => format!("ip6={}", addr),
})
} else {
None
}
})
.collect::<Vec<_>>();
// TODO: make this configurable
ip_config.push("ip6=auto".to_string());
let mut net = vec![
format!("virtio={}", value.vm.mac_address),
format!("bridge={}", self.config.bridge),
];
if let Some(t) = self.config.vlan {
net.push(format!("tag={}", t));
}
Ok(VmConfig {
cpu: Some(self.config.cpu.clone()),
kvm: Some(self.config.kvm),
ip_config: Some(ip_config.join(",")),
machine: Some(self.config.machine.clone()),
net: Some(net.join(",")),
os_type: Some(self.config.os_type.clone()),
on_boot: Some(true),
bios: Some(VmBios::OVMF),
boot: Some("order=scsi0".to_string()),
cores: Some(value.template.cpu as i32),
memory: Some((value.template.memory / 1024 / 1024).to_string()),
scsi_hw: Some("virtio-scsi-pci".to_string()),
serial_0: Some("socket".to_string()),
scsi_1: Some(format!("{}:cloudinit", &value.disk.name)),
ssh_keys: Some(urlencoding::encode(&value.ssh_key.key_data).to_string()),
efi_disk_0: Some(format!("{}:0,efitype=4m", &value.disk.name)),
..Default::default()
})
}
}
#[async_trait]
impl VmHostClient for ProxmoxClient {
async fn download_os_image(&self, image: &VmOsImage) -> Result<()> {
let iso_storage = self.get_iso_storage(&self.node).await?;
let files = self.list_storage_files(&self.node, &iso_storage).await?;
info!("Downloading image {} on {}", image.url, &self.node);
let i_name = image.filename()?;
if files
.iter()
.any(|v| v.vol_id.ends_with(&format!("iso/{i_name}")))
{
info!("Already downloaded, skipping");
return Ok(());
}
let t_download = self
.download_image(DownloadUrlRequest {
content: StorageContent::ISO,
node: self.node.clone(),
storage: iso_storage.clone(),
url: image.url.clone(),
filename: i_name,
})
.await?;
self.wait_for_task(&t_download).await?;
Ok(())
}
async fn generate_mac(&self, _vm: &Vm) -> Result<String> {
ensure!(self.mac_prefix.len() == 8, "Invalid mac prefix");
ensure!(self.mac_prefix.contains(":"), "Invalid mac prefix");
Ok(format!(
"{}:{}:{}:{}",
self.mac_prefix,
hex::encode([random::<u8>()]),
hex::encode([random::<u8>()]),
hex::encode([random::<u8>()])
))
}
async fn start_vm(&self, vm: &Vm) -> Result<()> {
let task = self.start_vm(&self.node, vm.id.into()).await?;
self.wait_for_task(&task).await?;
Ok(())
}
async fn stop_vm(&self, vm: &Vm) -> Result<()> {
let task = self.stop_vm(&self.node, vm.id.into()).await?;
self.wait_for_task(&task).await?;
Ok(())
}
async fn reset_vm(&self, vm: &Vm) -> Result<()> {
let task = self.reset_vm(&self.node, vm.id.into()).await?;
self.wait_for_task(&task).await?;
Ok(())
}
async fn create_vm(&self, req: &FullVmInfo) -> Result<()> {
let config = self.make_config(&req)?;
let vm_id = req.vm.id.into();
let t_create = self
.create_vm(CreateVm {
node: self.node.clone(),
vm_id,
config,
})
.await?;
self.wait_for_task(&t_create).await?;
// import primary disk from image (scsi0)
if let Err(e) = self
.import_disk_image(ImportDiskImageRequest {
vm_id,
node: self.node.clone(),
storage: req.disk.name.clone(),
disk: "scsi0".to_string(),
image: req.image.filename()?,
is_ssd: matches!(req.disk.kind, DiskType::SSD),
})
.await
{
// TODO: rollback
return Err(e);
}
// resize disk to match template
let j_resize = self
.resize_disk(ResizeDiskRequest {
node: self.node.clone(),
vm_id,
disk: "scsi0".to_string(),
size: req.template.disk_size.to_string(),
})
.await?;
// TODO: rollback
self.wait_for_task(&j_resize).await?;
// try start, otherwise ignore error (maybe its already running)
if let Ok(j_start) = self.start_vm(&self.node, vm_id).await {
if let Err(e) = self.wait_for_task(&j_start).await {
warn!("Failed to start vm: {}", e);
}
}
Ok(())
}
async fn get_vm_state(&self, vm: &Vm) -> Result<VmState> {
let s = self.get_vm_status(&self.node, vm.id.into()).await?;
Ok(VmState {
timestamp: Utc::now().timestamp() as u64,
state: match s.status {
VmStatus::Stopped => VmRunningState::Stopped,
VmStatus::Running => VmRunningState::Running,
},
cpu_usage: s.cpu.unwrap_or(0.0),
mem_usage: s.mem.unwrap_or(0) as f32 / s.max_mem.unwrap_or(1) as f32,
uptime: s.uptime.unwrap_or(0),
net_in: s.net_in.unwrap_or(0),
net_out: s.net_out.unwrap_or(0),
disk_write: s.disk_write.unwrap_or(0),
disk_read: s.disk_read.unwrap_or(0),
})
}
async fn configure_vm(&self, cfg: &FullVmInfo) -> Result<()> {
let mut config = self.make_config(&cfg)?;
// dont re-create the disks
config.scsi_0 = None;
config.scsi_1 = None;
config.efi_disk_0 = None;
self.configure_vm(ConfigureVm {
node: self.node.clone(),
vm_id: cfg.vm.id.into(),
current: None,
snapshot: None,
config,
})
.await?;
Ok(())
}
}
/// Wrap a database vm id
#[derive(Debug, Copy, Clone, Default)]
pub struct ProxmoxVmId(u64);
impl Into<i32> for ProxmoxVmId {
fn into(self) -> i32 {
self.0 as i32 + 100
}
}
impl From<u64> for ProxmoxVmId {
fn from(value: u64) -> Self {
Self(value)
}
}
impl From<i32> for ProxmoxVmId {
fn from(value: i32) -> Self {
Self(value as u64 - 100)
}
}
impl Display for ProxmoxVmId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let id: i32 = (*self).into();
write!(f, "{}", id)
}
}
impl Serialize for ProxmoxVmId {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let id: i32 = (*self).into();
serializer.serialize_i32(id)
}
}
impl<'de> Deserialize<'de> for ProxmoxVmId {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let id = i32::deserialize(deserializer)?;
Ok(id.into())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct TerminalProxyTicket {
pub port: String,
pub ticket: String,
pub upid: String,
pub user: String,
}
#[derive(Debug, Clone)]
pub struct TaskId {
pub id: String,
pub node: String,
}
#[derive(Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TaskState {
Running,
Stopped,
}
#[derive(Deserialize)]
pub struct TaskStatus {
pub id: String,
pub node: String,
pub pid: u32,
#[serde(rename = "pstart")]
pub p_start: u64,
#[serde(rename = "starttime")]
pub start_time: u64,
pub status: TaskState,
#[serde(rename = "type")]
pub task_type: String,
#[serde(rename = "upid")]
pub up_id: String,
pub user: String,
#[serde(rename = "exitstatus")]
pub exit_status: Option<String>,
}
impl TaskStatus {
pub fn is_finished(&self) -> bool {
self.status == TaskState::Stopped
}
pub fn is_success(&self) -> bool {
self.is_finished() && self.exit_status == Some("OK".to_string())
}
}
#[derive(Deserialize)]
pub struct ResponseBase<T> {
pub data: T,
}
#[derive(Deserialize)]
pub struct VersionResponse {
#[serde(rename = "repoid")]
pub repo_id: String,
pub version: String,
pub release: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NodeStatus {
Unknown,
Online,
Offline,
}
#[derive(Debug, Deserialize)]
pub struct NodeResponse {
#[serde(rename = "node")]
pub name: String,
pub status: NodeStatus,
pub cpu: Option<f32>,
pub support: Option<String>,
#[serde(rename = "maxcpu")]
pub max_cpu: Option<u16>,
#[serde(rename = "maxmem")]
pub max_mem: Option<u64>,
pub mem: Option<u64>,
pub uptime: Option<u64>,
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum VmStatus {
Stopped,
Running,
}
#[derive(Debug, Deserialize)]
pub struct VmInfo {
pub status: VmStatus,
#[serde(rename = "vmid")]
pub vm_id: i32,
pub cpus: Option<u16>,
#[serde(rename = "maxdisk")]
pub max_disk: Option<u64>,
#[serde(rename = "maxmem")]
pub max_mem: Option<u64>,
pub name: Option<String>,
pub tags: Option<String>,
pub uptime: Option<u64>,
pub cpu: Option<f32>,
pub mem: Option<u64>,
#[serde(rename = "netin")]
pub net_in: Option<u64>,
#[serde(rename = "netout")]
pub net_out: Option<u64>,
#[serde(rename = "diskwrite")]
pub disk_write: Option<u64>,
#[serde(rename = "diskread")]
pub disk_read: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StorageType {
LVMThin,
Dir,
ZFSPool,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum StorageContent {
Images,
RootDir,
Backup,
ISO,
VZTmpL,
Import,
}
impl FromStr for StorageContent {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"images" => Ok(StorageContent::Images),
"rootdir" => Ok(StorageContent::RootDir),
"backup" => Ok(StorageContent::Backup),
"iso" => Ok(StorageContent::ISO),
"vztmpl" => Ok(StorageContent::VZTmpL),
"import" => Ok(StorageContent::Import),
_ => Err(()),
}
}
}
#[derive(Debug, Deserialize)]
pub struct NodeStorage {
pub content: String,
pub storage: String,
#[serde(rename = "type")]
pub kind: Option<StorageType>,
#[serde(rename = "thinpool")]
pub thin_pool: Option<String>,
}
impl NodeStorage {
pub fn contents(&self) -> Vec<StorageContent> {
self.content
.split(",")
.map_while(|s| s.parse().ok())
.collect()
}
}
#[derive(Debug, Serialize)]
pub struct DownloadUrlRequest {
pub content: StorageContent,
pub node: String,
pub storage: String,
pub url: String,
pub filename: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct StorageContentEntry {
pub format: String,
pub size: u64,
#[serde(rename = "volid")]
pub vol_id: String,
#[serde(rename = "vmid")]
pub vm_id: Option<u32>,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct ResizeDiskRequest {
pub node: String,
#[serde(rename = "vmid")]
pub vm_id: ProxmoxVmId,
pub disk: String,
/// The new size.
///
/// With the `+` sign the value is added to the actual size of the volume and without it,
/// the value is taken as an absolute one. Shrinking disk size is not supported.
pub size: String,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct ImportDiskImageRequest {
/// VM id
pub vm_id: ProxmoxVmId,
/// Node name
pub node: String,
/// Storage pool to import disk to
pub storage: String,
/// Disk name (scsi0 etc)
pub disk: String,
/// Image filename on disk inside the disk storage dir
pub image: String,
/// If the disk is an SSD and discard should be enabled
pub is_ssd: bool,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VmBios {
SeaBios,
OVMF,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct CreateVm {
pub node: String,
#[serde(rename = "vmid")]
pub vm_id: ProxmoxVmId,
#[serde(flatten)]
pub config: VmConfig,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct ConfigureVm {
pub node: String,
#[serde(rename = "vmid")]
pub vm_id: ProxmoxVmId,
#[serde(skip_serializing_if = "Option::is_none")]
pub current: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snapshot: Option<String>,
#[serde(flatten)]
pub config: VmConfig,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct VmConfig {
#[serde(rename = "onboot")]
#[serde(skip_serializing_if = "Option::is_none")]
pub on_boot: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub balloon: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bios: Option<VmBios>,
#[serde(skip_serializing_if = "Option::is_none")]
pub boot: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cores: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cpu: Option<String>,
#[serde(rename = "ipconfig0")]
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_config: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub machine: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "net0")]
#[serde(skip_serializing_if = "Option::is_none")]
pub net: Option<String>,
#[serde(rename = "ostype")]
#[serde(skip_serializing_if = "Option::is_none")]
pub os_type: Option<String>,
#[serde(rename = "scsi0")]
#[serde(skip_serializing_if = "Option::is_none")]
pub scsi_0: Option<String>,
#[serde(rename = "scsi1")]
#[serde(skip_serializing_if = "Option::is_none")]
pub scsi_1: Option<String>,
#[serde(rename = "scsihw")]
#[serde(skip_serializing_if = "Option::is_none")]
pub scsi_hw: Option<String>,
#[serde(rename = "sshkeys")]
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_keys: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<String>,
#[serde(rename = "efidisk0")]
#[serde(skip_serializing_if = "Option::is_none")]
pub efi_disk_0: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kvm: Option<bool>,
#[serde(rename = "serial0")]
#[serde(skip_serializing_if = "Option::is_none")]
pub serial_0: Option<String>,
}