//! Virtual machine specification. use crate::config::Configuration; use log::debug; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct OneVmInputSpecification { #[serde(default)] pub ssh_key_files: Option>, pub rsa_host_key: Option, pub rsa_host_cert: Option, pub dsa_host_key: Option, pub dsa_host_cert: Option, pub ecdsa_host_key: Option, pub ecdsa_host_cert: Option, pub ed25519_host_key: Option, pub ed25519_host_cert: Option, pub base: Option, pub image: Option, pub image_size_gib: Option, pub memory_mib: Option, pub cpus: Option, pub generate_host_certificate: Option, pub ca_key: Option, } impl OneVmInputSpecification { fn ssh_key_files( &self, config: &Configuration, name: &str, ) -> Result, SpecificationError> { get( &self.ssh_key_files, &config.authorized_keys, SpecificationError::NoAuthorizedKeys(name.to_string()), ) } fn base_image( &self, config: &Configuration, name: &str, ) -> Result { get( &self.base, &config.default_base_image, SpecificationError::NoBaseImage(name.to_string()), ) } fn image(&self, config: &Configuration, name: &str) -> Result { let default_image = if let Some(dirname) = &config.image_directory { Some(dirname.join(format!("{}.qcow2", name))) } else { None }; get( &self.image, &default_image, SpecificationError::NoBaseImage(name.to_string()), ) } fn image_size_gib( &self, config: &Configuration, name: &str, ) -> Result { get( &self.image_size_gib, &config.default_image_gib, SpecificationError::NoBaseImage(name.to_string()), ) } fn memory_mib(&self, config: &Configuration, name: &str) -> Result { get( &self.memory_mib, &config.default_memory_mib, SpecificationError::NoBaseImage(name.to_string()), ) } fn cpus(&self, config: &Configuration, name: &str) -> Result { get( &self.cpus, &config.default_cpus, SpecificationError::NoBaseImage(name.to_string()), ) } } fn get<'a, T>( input: &'a Option, default: &'a Option, error: SpecificationError, ) -> Result where T: Clone, { if let Some(input) = input { Ok((*input).clone()) } else if let Some(default) = default { Ok((*default).clone()) } else { Err(error) } } /// Effective virtual machine specification. /// /// This is the specification as read from the input file, with the /// defaults from the configuration file already applied. #[derive(Debug)] pub struct Specification { /// Name of new virtual machine to create. pub name: String, /// SSH public keys to install in the default user's `authorized_keys` file. pub ssh_keys: Vec, /// RSA host key to install in new VM. pub rsa_host_key: Option, /// RSA host certificate. pub rsa_host_cert: Option, /// DSA host key to install in new VM. pub dsa_host_key: Option, /// DSA host certificate. pub dsa_host_cert: Option, /// ECDSA host key to install in new VM. pub ecdsa_host_key: Option, /// ECDSA host certificate. pub ecdsa_host_cert: Option, /// Ed25519 host key to install in new VM. pub ed25519_host_key: Option, /// Ed25519 host certificate. pub ed25519_host_cert: Option, /// Path to base image. pub base: PathBuf, /// Path to new VM image, to be created. pub image: PathBuf, /// Size of new image, in GiB. pub image_size_gib: u64, /// Size of memory for new VM, in MiB. pub memory_mib: u64, /// CPUs new VM should have. pub cpus: u64, /// Should a new host key and certificate be created for new VM? pub generate_host_certificate: bool, /// Path to CA key for creating host certificate. pub ca_key: Option, } /// Errors from this module. #[derive(Debug, thiserror::Error)] pub enum SpecificationError { /// No base image file specified. #[error("No base image or default base image specified for {0}")] NoBaseImage(String), /// No image file specified. #[error("No image filename specified for {0} and no image_directory in configuration")] NoImage(String), /// No image size specified. #[error("No image size specified for {0} and no default configured")] NoImageSize(String), /// No memory size specified. #[error("No memory size specified for {0} and no default configured")] NoMemorySize(String), /// No CPU count specified. #[error("No CPU count specified for {0} and no default configured")] NoCpuCount(String), /// No SSH authorized keys specified. #[error("No SSH authorized keys specified for {0} and no default configured")] NoAuthorizedKeys(String), /// Error reading SSH public key. #[error("Failed to read SSH public key file {0}")] SshKeyRead(PathBuf, #[source] std::io::Error), /// I/O error. #[error(transparent)] IoError(#[from] std::io::Error), /// Error parsing string as UTF8. #[error(transparent)] FromUtf8Error(#[from] std::string::FromUtf8Error), /// Error parsing YAML. #[error(transparent)] YamlError(#[from] serde_yaml::Error), } impl Specification { /// Read all specifications from a file. /// /// Apply values from the provided configuration so that the /// returned specifications are *effective* and the caller doesn't /// need to worry about the configuration anymore. /// /// Also, SSH public keys are read from the files named in the /// input specification. pub fn from_file( config: &Configuration, filename: &Path, ) -> Result, SpecificationError> { debug!("reading specification from {}", filename.display()); let spec = fs::read(filename)?; let input: HashMap = serde_yaml::from_slice(&spec)?; debug!("specification as read from file: {:#?}", input); let mut machines = vec![]; for (name, machine) in input.iter() { let spec = Specification::one_machine(config, &name, &machine)?; debug!("machine with defaults applied: {:#?}", spec); machines.push(spec); } Ok(machines) } fn one_machine( config: &Configuration, name: &str, input: &OneVmInputSpecification, ) -> Result { let key_filenames = input.ssh_key_files(config, name)?; let ssh_keys = ssh_keys(&key_filenames)?; let ca_key = if let Some(filename) = &input.ca_key { Some(filename.clone()) } else { config.ca_key.clone() }; let gen_cert = if let Some(v) = &input.generate_host_certificate { *v } else if let Some(v) = &config.default_generate_host_certificate { *v } else { false }; let spec = Specification { name: name.to_string(), ssh_keys, rsa_host_key: input.rsa_host_key.clone(), rsa_host_cert: input.rsa_host_cert.clone(), dsa_host_key: input.dsa_host_key.clone(), dsa_host_cert: input.dsa_host_cert.clone(), ecdsa_host_key: input.ecdsa_host_key.clone(), ecdsa_host_cert: input.ecdsa_host_cert.clone(), ed25519_host_key: input.ed25519_host_key.clone(), ed25519_host_cert: input.ed25519_host_cert.clone(), base: input.base_image(config, name)?, image: input.image(config, name)?, image_size_gib: input.image_size_gib(config, name)?, memory_mib: input.memory_mib(config, name)?, cpus: input.cpus(config, name)?, generate_host_certificate: gen_cert, ca_key, }; debug!("specification as with defaults applied: {:#?}", spec); Ok(spec) } } fn ssh_keys(filenames: &[PathBuf]) -> Result, SpecificationError> { let mut keys = vec![]; for filename in filenames { let key = std::fs::read(filename) .map_err(|e| SpecificationError::SshKeyRead(filename.to_path_buf(), e))?; let key = String::from_utf8(key)?; let key = key.strip_suffix("\n").or(Some(&key)).unwrap(); keys.push(key.to_string()); } Ok(keys) }