use crate::{Boot, BootSpec, CloudInit, Error, Lifecycle, Machine, PowerState, Result};
#[cfg(feature = "backend-tunnr")]
use crate::ImageSource;
#[cfg(feature = "backend-tunnr")]
use std::collections::HashMap;
#[cfg(feature = "backend-tunnr")]
use std::sync::{Arc, Mutex};
#[cfg(feature = "backend-tunnr")]
type LiveHandles = Arc<Mutex<HashMap<String, tunnr_vm::BootHandle>>>;
#[derive(Default, Clone)]
pub struct KvmBoot {
#[cfg(feature = "backend-tunnr")]
live: LiveHandles,
}
impl std::fmt::Debug for KvmBoot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KvmBoot").finish_non_exhaustive()
}
}
impl KvmBoot {
pub fn new() -> Self {
Self::default()
}
pub fn nocloud_seed_files(ci: &CloudInit) -> Vec<(&'static str, String)> {
let md = ci
.meta_data
.clone()
.unwrap_or_else(|| "instance-id: draupnir\nlocal-hostname: draupnir\n".to_string());
let mut files = vec![("user-data", ci.user_data.clone()), ("meta-data", md)];
if let Some(nc) = &ci.network_config {
files.push(("network-config", nc.clone()));
}
files
}
#[cfg(feature = "backend-tunnr")]
pub fn to_tunnr_spec(&self, spec: &BootSpec) -> Result<tunnr_vm::BootSpec> {
use std::path::PathBuf;
use std::time::Duration;
let (kernel, rootfs_or_disk) = match &spec.image {
ImageSource::KernelRootfs { kernel, rootfs } => (kernel.clone(), rootfs.clone()),
ImageSource::Disk(disk) => {
(spec.cmdline_kernel(), disk.clone())
}
other => {
return Err(Error::Spec(format!("kvm backend cannot boot {other:?}")))
}
};
let mut boot = tunnr_vm::BootSpec::smoke(PathBuf::from(kernel), PathBuf::from(rootfs_or_disk));
boot.mem_mb = spec.mem_mb;
boot.cores = spec.cores;
boot.headless = true;
if let Some(secs) = std::env::var("DRAUPNIR_VM_BOOT_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
{
boot.timeout = Duration::from_secs(secs);
}
if !spec.cmdline.is_empty() {
boot.kernel_cmdline = spec.cmdline.clone();
}
if let Some(ci) = &spec.cloud_init {
let seed = Self::build_seed_image(&Self::seed_path(&spec.name), ci)?;
boot.extra_qemu_args.push("-drive".into());
boot.extra_qemu_args.push(format!(
"file={},format=raw,if=virtio,readonly=on",
seed.display()
));
}
Ok(boot)
}
#[cfg(feature = "backend-tunnr")]
fn seed_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("draupnir-{name}-seed.img"))
}
#[cfg(feature = "backend-tunnr")]
pub fn build_seed_image(out: &std::path::Path, ci: &CloudInit) -> Result<std::path::PathBuf> {
let blobs: Vec<(String, Vec<u8>)> = Self::nocloud_seed_files(ci)
.into_iter()
.map(|(n, c)| (n.to_string(), c.into_bytes()))
.collect();
Self::build_fat_image(out, "cidata", &blobs)?;
Ok(out.to_path_buf())
}
#[cfg(feature = "backend-tunnr")]
fn build_fat_image(out: &std::path::Path, label: &str, files: &[(String, Vec<u8>)]) -> Result<()> {
use std::io::Write;
if let Some(dir) = out.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| Error::Backend(format!("mkdir {}: {e}", dir.display())))?;
}
let payload: u64 = files.iter().map(|(_, b)| b.len() as u64).sum();
let needed = (payload + 1024 * 1024).max(2 * 1024 * 1024);
let size = needed.div_ceil(512 * 1024) * 512 * 1024;
let mut cursor = std::io::Cursor::new(vec![0u8; size as usize]);
let mut lbl = [b' '; 11];
for (i, b) in label.bytes().take(11).enumerate() {
lbl[i] = b;
}
fatfs::format_volume(&mut cursor, fatfs::FormatVolumeOptions::new().volume_label(lbl))
.map_err(|e| Error::Backend(format!("format FAT {}: {e}", out.display())))?;
{
let fs = fatfs::FileSystem::new(&mut cursor, fatfs::FsOptions::new())
.map_err(|e| Error::Backend(format!("open FAT {}: {e}", out.display())))?;
let root = fs.root_dir();
for (name, bytes) in files {
let mut file = root
.create_file(name)
.map_err(|e| Error::Backend(format!("create {name} in FAT: {e}")))?;
file.truncate().ok();
file.write_all(bytes)
.map_err(|e| Error::Backend(format!("write {name} in FAT: {e}")))?;
file.flush().ok();
}
}
std::fs::write(out, cursor.into_inner())
.map_err(|e| Error::Backend(format!("write {}: {e}", out.display())))?;
Ok(())
}
}
impl Boot for KvmBoot {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
spec.validate()?;
#[cfg(feature = "backend-tunnr")]
{
let tunnr_spec = self.to_tunnr_spec(spec)?;
let handle = tunnr_vm::boot_test(tunnr_spec)
.map_err(|e| Error::Backend(format!("tunnr boot_test: {e}")))?;
let id = handle.id().to_string();
let machine = Machine::started(&id, spec);
self.live.lock().unwrap().insert(id, handle);
Ok(machine)
}
#[cfg(not(feature = "backend-tunnr"))]
{
let _ = spec;
Err(Error::Unsupported(
"kvm backend needs the `backend-tunnr` feature (drives tunnr's KVM boot primitive)"
.into(),
))
}
}
}
impl Lifecycle for KvmBoot {
fn power_on(&self, machine: &Machine) -> Result<()> {
let _ = machine;
Err(Error::Unsupported(
"kvm/tunnr VMs are fire-and-forget: re-power by calling draupnir::boot() again".into(),
))
}
fn power_off(&self, machine: &Machine) -> Result<()> {
#[cfg(feature = "backend-tunnr")]
{
let handle = self
.live
.lock()
.unwrap()
.remove(&machine.id)
.ok_or_else(|| Error::Backend(format!("no live tunnr VM for {}", machine.id)))?;
handle.kill();
Ok(())
}
#[cfg(not(feature = "backend-tunnr"))]
{
let _ = machine;
Err(Error::Unsupported(
"kvm backend needs the `backend-tunnr` feature".into(),
))
}
}
fn status(&self, machine: &Machine) -> Result<PowerState> {
#[cfg(feature = "backend-tunnr")]
{
let guard = self.live.lock().unwrap();
let Some(handle) = guard.get(&machine.id) else {
return Ok(PowerState::Unknown);
};
Ok(map_status(handle.poll_status()))
}
#[cfg(not(feature = "backend-tunnr"))]
{
let _ = machine;
Err(Error::Unsupported(
"kvm backend needs the `backend-tunnr` feature".into(),
))
}
}
}
#[cfg(feature = "backend-tunnr")]
fn map_status(s: tunnr_vm::BootStatus) -> PowerState {
match s {
tunnr_vm::BootStatus::Booting | tunnr_vm::BootStatus::BootedOk => PowerState::On,
tunnr_vm::BootStatus::Failed(_)
| tunnr_vm::BootStatus::Killed
| tunnr_vm::BootStatus::TimedOut => PowerState::Off,
}
}
#[cfg(feature = "backend-tunnr")]
impl BootSpec {
fn cmdline_kernel(&self) -> String {
String::new()
}
}
#[cfg(test)]
mod seed_tests {
use super::*;
#[test]
fn nocloud_seed_files_render_user_data_and_a_default_meta_data() {
let ci = CloudInit::user_data("#cloud-config\nruncmd:\n - [echo, hi]\n");
let files = KvmBoot::nocloud_seed_files(&ci);
assert_eq!(files[0].0, "user-data");
assert_eq!(files[0].1, "#cloud-config\nruncmd:\n - [echo, hi]\n");
assert_eq!(files[1].0, "meta-data");
assert!(files[1].1.contains("instance-id"), "default meta-data has an instance-id");
assert!(files[1].1.contains("local-hostname"));
}
#[test]
fn nocloud_seed_files_pass_through_an_explicit_meta_data() {
let ci = CloudInit {
user_data: "#cloud-config\n".into(),
meta_data: Some("instance-id: node-7\n".into()),
network_config: None,
};
let files = KvmBoot::nocloud_seed_files(&ci);
assert_eq!(files[1].1, "instance-id: node-7\n");
}
#[test]
fn nocloud_seed_files_omit_network_config_by_default_and_emit_it_when_set() {
let plain = CloudInit::user_data("#cloud-config\n");
let files = KvmBoot::nocloud_seed_files(&plain);
assert_eq!(files.len(), 2);
assert!(!files.iter().any(|(n, _)| *n == "network-config"));
let net = CloudInit::user_data("#cloud-config\n")
.with_network_config("version: 2\nethernets:\n eth0:\n dhcp4: true\n");
let files = KvmBoot::nocloud_seed_files(&net);
assert_eq!(files.len(), 3);
let nc = files.iter().find(|(n, _)| *n == "network-config").expect("network-config present");
assert!(nc.1.contains("dhcp4: true"));
}
}
#[cfg(all(test, feature = "backend-tunnr"))]
mod tests {
use super::*;
use crate::ImageSource;
#[test]
fn build_seed_image_writes_a_cidata_fat_with_both_files() {
let out = std::env::temp_dir()
.join(format!("draupnir-seedtest-{}.img", std::process::id()));
let _ = std::fs::remove_file(&out);
let ci = CloudInit::user_data("#cloud-config\nruncmd:\n - [echo, hi]\n");
let path = KvmBoot::build_seed_image(&out, &ci).unwrap();
assert_eq!(path, out);
let img = std::fs::File::options().read(true).write(true).open(&out).unwrap();
let fs = fatfs::FileSystem::new(img, fatfs::FsOptions::new()).unwrap();
assert_eq!(fs.volume_label().to_ascii_lowercase(), "cidata");
let mut names: Vec<String> = fs.root_dir().iter().map(|e| e.unwrap().file_name()).collect();
names.sort();
assert!(names.iter().any(|n| n == "user-data"), "user-data present, got {names:?}");
assert!(names.iter().any(|n| n == "meta-data"), "meta-data present, got {names:?}");
use std::io::Read;
let mut buf = String::new();
fs.root_dir().open_file("user-data").unwrap().read_to_string(&mut buf).unwrap();
assert!(buf.contains("runcmd"), "user-data content survives: {buf:?}");
let _ = std::fs::remove_file(&out);
}
#[test]
fn to_tunnr_spec_attaches_the_seed_drive_only_when_cloud_init_is_present() {
let plain = BootSpec::kvm_kernel_rootfs("plain", "/bzImage", "/rootfs.cpio.gz");
let t = KvmBoot::new().to_tunnr_spec(&plain).unwrap();
assert!(!t.extra_qemu_args.iter().any(|a| a.contains("seed.img")), "no seed drive without cloud-init");
let seeded = BootSpec::kvm_kernel_rootfs("seeded", "/bzImage", "/rootfs.cpio.gz")
.with_cloud_init(CloudInit::user_data("#cloud-config\n"));
let t = KvmBoot::new().to_tunnr_spec(&seeded).unwrap();
let drive = t
.extra_qemu_args
.windows(2)
.find(|w| w[0] == "-drive" && w[1].contains("draupnir-seeded-seed.img"))
.expect("a -drive for the seed image is appended");
assert!(drive[1].contains("format=raw"), "raw virtio drive: {:?}", drive[1]);
assert!(drive[1].contains("readonly=on"), "read-only: {:?}", drive[1]);
let _ = std::fs::remove_file(KvmBoot::seed_path("seeded"));
}
#[test]
fn kvm_spec_maps_kernel_rootfs_mem_and_cores_onto_tunnr() {
let mut spec = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz");
spec.mem_mb = 1024;
spec.cores = 4;
spec.cmdline = "korp.smoke=1".into();
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
assert_eq!(t.kernel, std::path::PathBuf::from("/bzImage"));
assert_eq!(t.rootfs_or_disk, std::path::PathBuf::from("/rootfs.cpio.gz"));
assert_eq!(t.mem_mb, 1024);
assert_eq!(t.cores, 4);
assert!(t.headless);
assert_eq!(t.kernel_cmdline, "korp.smoke=1");
}
#[test]
fn kvm_spec_rejects_a_non_kvm_image() {
let mut spec = BootSpec::kvm_kernel_rootfs("bad", "/k", "/r");
spec.image = ImageSource::OciImage("redis:7".into());
assert!(matches!(KvmBoot::new().to_tunnr_spec(&spec), Err(Error::Spec(_))));
}
#[test]
fn status_of_an_unknown_machine_is_unknown() {
let kvm = KvmBoot::new();
let m = Machine {
id: "boot-does-not-exist".into(),
spec_name: "x".into(),
backend: crate::Backend::Kvm,
power: PowerState::Unknown,
};
assert_eq!(kvm.status(&m).unwrap(), PowerState::Unknown);
}
#[test]
fn map_status_folds_live_states_to_on_and_every_terminal_to_off() {
assert_eq!(map_status(tunnr_vm::BootStatus::Booting), PowerState::On);
assert_eq!(map_status(tunnr_vm::BootStatus::BootedOk), PowerState::On);
assert_eq!(
map_status(tunnr_vm::BootStatus::Failed("qemu exited 1".into())),
PowerState::Off
);
assert_eq!(map_status(tunnr_vm::BootStatus::Killed), PowerState::Off);
assert_eq!(map_status(tunnr_vm::BootStatus::TimedOut), PowerState::Off);
}
}