nazrin/nzrd/src/cmd/vm.rs
snow flurry ba86368591 nzr-api, et al: implement a serializable ApiError
This replaces all the API functions that returned Result<T, String>.

Additionally, ToApiResult<T> and Simplify<T> make converting errors to
ApiError easier than with String.
2024-08-19 12:00:02 -07:00

282 lines
9.5 KiB
Rust

use nzr_api::error::{ApiError, ErrorType, ToApiResult};
use nzr_api::net::cidr::CidrV4;
use nzr_virt::error::DomainError;
use nzr_virt::xml::build::DomainBuilder;
use nzr_virt::xml::{self, InfoMap, SerialType, Sysinfo};
use nzr_virt::{datasize, dom, vol};
use tokio::sync::RwLock;
use crate::ctrl::vm::Progress;
use crate::ctx::Context;
use crate::model::tx::Transaction;
use crate::model::{Instance, Subnet};
use nzr_api::net::mac::MacAddr;
use nzr_api::{args, model, nzr_event};
use std::sync::Arc;
use tracing::{debug, info, warn};
const VIRT_MAC_OUI: &[u8] = &[0x02, 0xf1, 0x0f];
macro_rules! progress {
($task:ident, $pct:literal, $($arg:tt)*) => {{
let mut pt = $task.write().await;
pt.status_text = format!($($arg)*);
pt.percentage = $pct;
}};
}
/// Creates a new instance
pub async fn new_instance(
ctx: Context,
prog_task: Arc<RwLock<Progress>>,
args: &args::NewInstance,
) -> Result<(Instance, dom::Domain), ApiError> {
progress!(prog_task, 0.0, "Starting...");
// find the subnet corresponding to the interface
let subnet = Subnet::get_by_name(&ctx, &args.subnet)
.await
.to_api_with("Unable to get interface")?
.ok_or::<ApiError>(format!("Subnet {} wasn't found in database", &args.subnet).into())?;
// bail if a domain already exists
if let Ok(dom) = ctx.virt.conn.get_instance(&args.name).await {
Err(format!(
"Domain with name already exists (uuid {})",
dom.xml().await.uuid,
)
.into())
} else {
// make sure the base image exists
let mut base_image = ctx
.virt
.pools
.baseimg
.volume(&args.base_image)
.await
.to_api_with("Couldn't find base image")?;
progress!(prog_task, 10.0, "Generating metadata...");
// generate a new lease with a new MAC addr
let mac_addr = {
let bytes = [VIRT_MAC_OUI, rand::random::<[u8; 3]>().as_ref()].concat();
MacAddr::from_bytes(bytes)
}
.to_api_with("Unable to create a new MAC address")?;
// Get highest host addr + 1 for our new addr
let addr = {
let addr_num = Instance::all_in_subnet(&ctx, &subnet)
.await
.to_api_with("Couldn't get instances in subnet")?
.into_iter()
.max_by(|a, b| a.host_num.cmp(&b.host_num))
.map_or(subnet.start_host, |i| i.host_num + 1);
if addr_num > subnet.end_host || addr_num < subnet.start_host {
return Err("Got invalid lease address for instance".into());
}
let addr = subnet
.network
.make_ip(addr_num as u32)
.to_api_with("Unable to generate instance IP")?;
CidrV4::new(addr, subnet.network.cidr())
};
let lease = nzr_api::model::Lease {
subnet: subnet.name.clone(),
addr,
mac_addr,
};
// generate cloud-init data
let db_inst = {
let inst = Instance::insert(&ctx, &args.name, &subnet, lease.clone(), None)
.await
.to_api_type(ErrorType::Database)?;
Transaction::begin(&ctx, inst)
};
progress!(prog_task, 30.0, "Creating instance images...");
// create primary volume from base image
let mut pri_vol = base_image
.clone_vol(
&ctx.virt.pools.primary,
&args.name,
datasize!((args.disk_sizes.0) GiB),
)
.await
.to_api_with("Failed to clone base image")?;
// and, if it exists: the second volume
let sec_vol = match args.disk_sizes.1 {
Some(sec_size) => {
let voldata =
// TODO: Fix VolType
xml::Volume::new(&args.name, xml::VolType::Qcow2, datasize!(sec_size GiB));
Some(
vol::Volume::create(&ctx.virt.pools.secondary, voldata, 0)
.await
.to_api_with("Couldn't create secondary volume")?,
)
}
None => None,
};
// build domain xml
let ifname = subnet.ifname.clone();
let devname = format!(
"veth-{:02x}{:02x}{:02x}",
mac_addr[3], mac_addr[4], mac_addr[5]
);
progress!(prog_task, 60.0, "Initializing instance...");
let dom_xml = {
let pri_name = &ctx.config.storage.primary_pool;
let sec_name = &ctx.config.storage.secondary_pool;
let smbios_info = {
let mut sysinfo = Sysinfo::new();
let mut system_map = InfoMap::new();
system_map.push(
"serial",
format!("ds=nocloud-net;s={}", ctx.config.cloud.http_addr()),
);
sysinfo.system(system_map);
sysinfo
};
let mut instdata = DomainBuilder::default()
.name(&args.name)
.memory(datasize!((args.memory) MiB))
.cpu_topology(1, 1, args.cores, 1)
.net_device(|nd| {
nd.mac_addr(mac_addr)
.with_bridge(&ifname)
.target_dev(&devname)
})
.disk_device(|dsk| {
dsk.volume_source(pri_name, &pri_vol.name)
.target("vda", "virtio")
.qcow2()
.boot_order(1)
})
.smbios(smbios_info)
.serial_device(SerialType::Pty);
// add desription, if provided
instdata = match &args.description {
Some(desc) => instdata.description(desc),
None => instdata,
};
// add second volume, if provided
match &sec_vol {
Some(vol) => instdata.disk_device(|dsk| {
dsk.volume_source(sec_name, &vol.name)
.target("vdb", "virtio")
.qcow2()
}),
None => instdata,
}
.build()
};
let mut virt_dom = ctx
.virt
.conn
.define_instance(dom_xml)
.await
.to_api_with("Couldn't define libvirt instance")?;
// not a fatal error, we can set autostart afterward
if let Err(err) = virt_dom.autostart(true).await {
warn!("Couldn't set autostart for domain: {err}");
}
if let Err(err) = virt_dom.start().await {
warn!("Domain defined, but couldn't be started! Error: {err}");
}
// set all volumes to persistent to avoid deletion
pri_vol.persist = true;
if let Some(mut sec_vol) = sec_vol {
sec_vol.persist = true;
}
virt_dom.persist().await;
progress!(prog_task, 80.0, "Domain created!");
debug!("Domain {} created!", virt_dom.xml().await.name.as_str());
Ok((db_inst.take(), virt_dom))
}
}
pub async fn delete_instance(
ctx: Context,
name: String,
) -> Result<Option<model::Instance>, ApiError> {
let Some(inst_db) = Instance::get_by_name(&ctx, &name)
.await
.to_api_with_type(ErrorType::Database, "Couldn't find instance")?
else {
return Err(ErrorType::NotFound.into());
};
let api_model = match inst_db.api_model(&ctx).await {
Ok(model) => Some(model),
Err(err) => {
warn!("Couldn't get API model to notify clients: {err}");
None
}
};
// First, destroy the instance
match ctx.virt.conn.get_instance(name.clone()).await {
Ok(mut inst) => {
inst.stop().await.to_api_with("Couldn't stop instance")?;
inst.undefine(true)
.await
.to_api_with("Couldn't undefine instance")?;
}
Err(DomainError::DomainNotFound) => {
warn!("Deleting instance that exists in DB but not libvirt");
}
Err(err) => Err(ApiError::new(
nzr_api::error::ErrorType::VirtError,
"Couldn't get instance from libvirt",
err,
))?,
}
// Then, delete the DB entity
inst_db
.delete(&ctx)
.await
.to_api_with("Couldn't delete from database")?;
Ok(api_model)
}
/// Delete all instances that don't have a matching libvirt domain
pub async fn prune_instances(ctx: &Context) -> Result<(), Box<dyn std::error::Error>> {
for entity in Instance::all(ctx).await? {
if let Err(DomainError::DomainNotFound) = ctx.virt.conn.get_instance(&entity.name).await {
info!("Invalid domain {}, deleting", &entity.name);
// First, get the API model to notify clients with
let api_model = match entity.api_model(ctx).await {
Ok(ent) => Some(ent),
Err(err) => {
warn!("Couldn't get api model to notify clients: {err}");
None
}
};
// then, delete by name
let name = entity.name.clone();
if let Err(err) = entity.delete(ctx).await {
warn!("Couldn't delete {}: {}", name, err);
}
// and assuming all goes well, notify clients
if let Some(ent) = api_model {
nzr_event!(ctx.events, Deleted, ent);
}
}
}
Ok(())
}