From a4e59f7f6363c2bc62b91298f41b5b645842385e Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Mon, 1 Mar 2021 10:13:15 +0200 Subject: feat: change how command line interface works Easier to use now. --config before subcommand was annoying. --- Cargo.lock | 32 ++++++ Cargo.toml | 1 + src/bin/vmadm.rs | 262 +++++++++++++++++++++++--------------------------- src/cloudinit.rs | 2 +- src/cmd/cloud_init.rs | 28 ++++++ src/cmd/delete.rs | 43 +++++++++ src/cmd/list.rs | 31 ++++++ src/cmd/mod.rs | 11 +++ src/cmd/new.rs | 47 +++++++++ src/config.rs | 33 +++++++ src/lib.rs | 2 + src/spec.rs | 94 ++++++++++++++++-- 12 files changed, 431 insertions(+), 155 deletions(-) create mode 100644 src/cmd/cloud_init.rs create mode 100644 src/cmd/delete.rs create mode 100644 src/cmd/list.rs create mode 100644 src/cmd/mod.rs create mode 100644 src/cmd/new.rs create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 2a03b2b..1b1bcd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,6 +68,27 @@ dependencies = [ "vec_map", ] +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dtoa" version = "0.4.7" @@ -277,6 +298,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" +dependencies = [ + "getrandom", + "redox_syscall", +] + [[package]] name = "regex" version = "1.4.3" @@ -489,6 +520,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytesize", + "directories-next", "log", "pretty_env_logger", "serde", diff --git a/Cargo.toml b/Cargo.toml index 54db867..740246b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,4 @@ bytesize = "1" log = "0.4" pretty_env_logger = "0.4" shell-words = "1" +directories-next = "2" \ No newline at end of file diff --git a/src/bin/vmadm.rs b/src/bin/vmadm.rs index a2717ee..4cc2248 100644 --- a/src/bin/vmadm.rs +++ b/src/bin/vmadm.rs @@ -1,38 +1,67 @@ -use bytesize::GIB; -use log::{debug, info}; -use std::fs; -use std::net::TcpStream; +use anyhow::Context; +use directories_next::ProjectDirs; +use vmadm::cmd; +use vmadm::config::Configuration; +use vmadm::spec::Specification; +//use bytesize::GIB; +use log::debug; +//use std::fs; use std::path::{Path, PathBuf}; -use std::thread; -use std::time::Duration; +//use std::thread; use structopt::StructOpt; -use virt::connect::Connect; -use vmadm::cloudinit::CloudInitConfig; -use vmadm::image::VirtualMachineImage; -use vmadm::install::{virt_install, VirtInstallArgs}; -use vmadm::spec::Specification; +//use vmadm::cloudinit::CloudInitConfig; +//use vmadm::image::VirtualMachineImage; +//use vmadm::install::{virt_install, VirtInstallArgs}; +//use vmadm::spec::Specification; + +const QUALIFIER: &str = ""; +const ORG: &str = ""; +const APP: &str = "vmadm"; -const SSH_PORT: i32 = 22; +#[derive(StructOpt, Debug)] +struct Cli { + #[structopt(subcommand)] + cmd: Command, +} #[derive(StructOpt, Debug)] -enum Cli { +enum Command { New { + #[structopt(flatten)] + common: CommonOptions, + #[structopt(parse(from_os_str))] spec: PathBuf, }, - List, + + List { + #[structopt(flatten)] + common: CommonOptions, + }, + Delete { + #[structopt(flatten)] + common: CommonOptions, + #[structopt(parse(from_os_str))] spec: PathBuf, }, + CloudInit { + #[structopt(flatten)] + common: CommonOptions, + #[structopt(parse(from_os_str))] spec: PathBuf, #[structopt(parse(from_os_str))] dirname: PathBuf, }, + CloudInitIso { + #[structopt(flatten)] + common: CommonOptions, + #[structopt(parse(from_os_str))] spec: PathBuf, @@ -41,158 +70,103 @@ enum Cli { }, } -fn main() -> anyhow::Result<()> { - pretty_env_logger::init(); - match Cli::from_args() { - Cli::New { spec } => new(&spec)?, - Cli::List => list()?, - Cli::Delete { spec } => delete(&spec)?, - Cli::CloudInit { spec, dirname } => cloud_init(&spec, &dirname)?, - Cli::CloudInitIso { spec, iso } => cloud_init_iso(&spec, &iso)?, - } - Ok(()) +#[derive(StructOpt, Debug)] +struct CommonOptions { + #[structopt(short, long, parse(from_os_str))] + config: Option, } -fn new(spec: &Path) -> anyhow::Result<()> { - info!("creating new VM"); - - debug!("reading specification from {}", spec.display()); - let spec = fs::read(spec)?; - let spec: Specification = serde_yaml::from_slice(&spec)?; - - info!("creating cloud-init config"); - let init = CloudInitConfig::from(&spec)?; - - info!( - "creating VM image {} from {}", - spec.image.display(), - spec.base.display() - ); - let image = VirtualMachineImage::new_from_base(&spec.base, &spec.image)?; - - info!("resizing image to {} GiB", spec.image_size_gib); - image.resize(spec.image_size_gib * GIB)?; - - info!("creating VM"); - let mut args = VirtInstallArgs::new(&spec.name, &image, &init); - args.set_memory(spec.memory_mib); - args.set_vcpus(spec.cpus); - virt_install(&args)?; +fn main() -> anyhow::Result<()> { + pretty_env_logger::init_custom_env("VMADM_LOG"); + let cli = Cli::from_args(); + debug!("{:#?}", cli); + + match cli.cmd { + Command::New { common, spec } => { + let spec = get_spec(&common, &spec)?; + cmd::new(&spec)?; + } - info!("waiting for {} to open its SSH port", spec.name); - wait_for_port(&spec.name, SSH_PORT)?; + Command::List { common } => { + let config = config(&common)?; + cmd::list(&config)?; + } - Ok(()) -} + Command::Delete { common, spec } => { + let spec = get_spec(&common, &spec)?; + cmd::delete(&spec)?; + } -fn wait_for_port(name: &str, port: i32) -> anyhow::Result<()> { - let addr = format!("{}:{}", name, port); - loop { - if TcpStream::connect(&addr).is_ok() { - return Ok(()); + Command::CloudInit { + common, + spec, + dirname, + } => { + let spec = get_spec(&common, &spec)?; + cmd::cloud_init(&spec, &dirname)?; + } + Command::CloudInitIso { common, spec, iso } => { + let spec = get_spec(&common, &spec)?; + cmd::cloud_init_iso(&spec, &iso)?; } } + Ok(()) } -fn list() -> anyhow::Result<()> { - let conn = Connect::open("qemu:///system")?; - let domains = conn.list_all_domains(0)?; - for domain in domains { - let name = domain.get_name()?; - let (state, _) = domain.get_state()?; - let state = state_name(state); - println!("{} {}", name, state); - } - - Ok(()) +fn get_spec(common: &CommonOptions, spec: &Path) -> anyhow::Result { + let config = config(&common)?; + let spec = Specification::from_file(&config, &spec)?; + Ok(spec) } -fn state_name(state: virt::domain::DomainState) -> String { - let name = match state { - virt::domain::VIR_DOMAIN_NOSTATE => "none", - virt::domain::VIR_DOMAIN_RUNNING => "running", - virt::domain::VIR_DOMAIN_BLOCKED => "blocked", - virt::domain::VIR_DOMAIN_PAUSED => "paused", - virt::domain::VIR_DOMAIN_SHUTDOWN => "shutdown", - virt::domain::VIR_DOMAIN_SHUTOFF => "shutoff", - virt::domain::VIR_DOMAIN_CRASHED => "crashed", - virt::domain::VIR_DOMAIN_PMSUSPENDED => "power management suspended", - _ => "unknown", - }; - name.to_string() +fn config(common: &CommonOptions) -> anyhow::Result { + let filename = config_filename(common); + let config = Configuration::from_file(&filename) + .with_context(|| format!("reading configuration file {}", filename.display()))?; + Ok(config) } -fn delete(spec: &Path) -> anyhow::Result<()> { - info!("deleting virtual machine specified in {}", spec.display()); - - debug!("reading specification from {}", spec.display()); - let spec = fs::read(spec)?; - let spec: Specification = serde_yaml::from_slice(&spec)?; - - debug!("connecting to libvirtd"); - let conn = Connect::open("qemu:///system")?; - - debug!("listing all domains"); - let domains = conn.list_all_domains(0)?; - - for domain in domains { - debug!("considering {}", domain.get_name()?); - if domain.get_name()? == spec.name { - debug!("shutdown {}", spec.name); - domain.shutdown().ok(); - - let briefly = Duration::from_millis(1000); - loop { - thread::sleep(briefly); - match domain.is_active() { - Ok(true) => (), - Ok(false) => break, - Err(err) => { - debug!("is_active: {}", err); - } - } - debug!("{} is still running", spec.name); - } - - debug!("undefine {}", spec.name); - domain.undefine()?; - - debug!("removing image file {}", spec.image.display()); - std::fs::remove_file(&spec.image)?; +fn config_filename(common: &CommonOptions) -> PathBuf { + if let Some(ref filename) = common.config { + filename.to_path_buf() + } else { + if let Some(dirs) = ProjectDirs::from(QUALIFIER, ORG, APP) { + dirs.config_dir().join("config.yaml") + } else { + PathBuf::from("xxx") } } - Ok(()) } -fn cloud_init(spec: &Path, dirname: &Path) -> anyhow::Result<()> { - info!("generating cloud-init configuration"); +// fn cloud_init(spec: &Path, dirname: &Path) -> anyhow::Result<()> { +// info!("generating cloud-init configuration"); - debug!("reading specification from {}", spec.display()); - let spec = fs::read(spec)?; - let spec: Specification = serde_yaml::from_slice(&spec)?; - debug!("spec:\n{:#?}", spec); +// debug!("reading specification from {}", spec.display()); +// let spec = fs::read(spec)?; +// let spec: Specification = serde_yaml::from_slice(&spec)?; +// debug!("spec:\n{:#?}", spec); - info!("creating cloud-init config"); - let init = CloudInitConfig::from(&spec)?; +// info!("creating cloud-init config"); +// let init = CloudInitConfig::from(&spec)?; - debug!("creating directory {}", dirname.display()); - std::fs::create_dir_all(dirname)?; - init.create_dir(dirname)?; +// debug!("creating directory {}", dirname.display()); +// std::fs::create_dir_all(dirname)?; +// init.create_dir(dirname)?; - Ok(()) -} +// Ok(()) +// } -fn cloud_init_iso(spec: &Path, iso: &Path) -> anyhow::Result<()> { - info!("generating cloud-init ISO"); +// fn cloud_init_iso(spec: &Path, iso: &Path) -> anyhow::Result<()> { +// info!("generating cloud-init ISO"); - debug!("reading specification from {}", spec.display()); - let spec = fs::read(spec)?; - let spec: Specification = serde_yaml::from_slice(&spec)?; - debug!("spec:\n{:#?}", spec); +// debug!("reading specification from {}", spec.display()); +// let spec = fs::read(spec)?; +// let spec: Specification = serde_yaml::from_slice(&spec)?; +// debug!("spec:\n{:#?}", spec); - info!("creating cloud-init config"); - let init = CloudInitConfig::from(&spec)?; - init.create_iso(iso)?; +// info!("creating cloud-init config"); +// let init = CloudInitConfig::from(&spec)?; +// init.create_iso(iso)?; - Ok(()) -} +// Ok(()) +// } diff --git a/src/cloudinit.rs b/src/cloudinit.rs index 2c521fd..6e62cc5 100644 --- a/src/cloudinit.rs +++ b/src/cloudinit.rs @@ -147,7 +147,7 @@ struct Userdata { impl Userdata { fn from(spec: &Specification) -> Result { Ok(Self { - ssh_authorized_keys: spec.ssh_keys()?.lines().map(|s| s.to_string()).collect(), + ssh_authorized_keys: spec.ssh_keys.clone(), ssh_keys: Hostkeys::from(spec), runcmd: vec![ format!("python3 -c {}", quote(SCRIPT)), diff --git a/src/cmd/cloud_init.rs b/src/cmd/cloud_init.rs new file mode 100644 index 0000000..3ab4139 --- /dev/null +++ b/src/cmd/cloud_init.rs @@ -0,0 +1,28 @@ +use crate::cloudinit::CloudInitConfig; +use crate::spec::Specification; +use log::{debug, info}; +use std::path::Path; + +pub fn cloud_init(spec: &Specification, dirname: &Path) -> anyhow::Result<()> { + info!( + "generating cloud-init configuration into {}", + dirname.display() + ); + + let init = CloudInitConfig::from(&spec)?; + + debug!("creating directory {}", dirname.display()); + std::fs::create_dir_all(dirname)?; + init.create_dir(dirname)?; + + Ok(()) +} + +pub fn cloud_init_iso(spec: &Specification, iso: &Path) -> anyhow::Result<()> { + info!("generating cloud-init ISO into {}", iso.display()); + + let init = CloudInitConfig::from(&spec)?; + init.create_iso(iso)?; + + Ok(()) +} diff --git a/src/cmd/delete.rs b/src/cmd/delete.rs new file mode 100644 index 0000000..ee8f0e9 --- /dev/null +++ b/src/cmd/delete.rs @@ -0,0 +1,43 @@ +use crate::spec::Specification; +use log::{debug, info}; +use std::thread; +use std::time::Duration; +use virt::connect::Connect; + +pub fn delete(spec: &Specification) -> anyhow::Result<()> { + info!("deleting virtual machine {}", spec.name); + + debug!("connecting to libvirtd"); + let conn = Connect::open("qemu:///system")?; + + debug!("listing all domains"); + let domains = conn.list_all_domains(0)?; + + for domain in domains { + debug!("considering {}", domain.get_name()?); + if domain.get_name()? == spec.name { + debug!("shutdown {}", spec.name); + domain.shutdown().ok(); + + let briefly = Duration::from_millis(1000); + loop { + thread::sleep(briefly); + match domain.is_active() { + Ok(true) => (), + Ok(false) => break, + Err(err) => { + debug!("is_active: {}", err); + } + } + debug!("{} is still running", spec.name); + } + + debug!("undefine {}", spec.name); + domain.undefine()?; + + debug!("removing image file {}", spec.image.display()); + std::fs::remove_file(&spec.image)?; + } + } + Ok(()) +} diff --git a/src/cmd/list.rs b/src/cmd/list.rs new file mode 100644 index 0000000..e58db3d --- /dev/null +++ b/src/cmd/list.rs @@ -0,0 +1,31 @@ +use crate::config::Configuration; + +use virt::connect::Connect; + +pub fn list(_config: &Configuration) -> anyhow::Result<()> { + let conn = Connect::open("qemu:///system")?; + let domains = conn.list_all_domains(0)?; + for domain in domains { + let name = domain.get_name()?; + let (state, _) = domain.get_state()?; + let state = state_name(state); + println!("{} {}", name, state); + } + + Ok(()) +} + +fn state_name(state: virt::domain::DomainState) -> String { + let name = match state { + virt::domain::VIR_DOMAIN_NOSTATE => "none", + virt::domain::VIR_DOMAIN_RUNNING => "running", + virt::domain::VIR_DOMAIN_BLOCKED => "blocked", + virt::domain::VIR_DOMAIN_PAUSED => "paused", + virt::domain::VIR_DOMAIN_SHUTDOWN => "shutdown", + virt::domain::VIR_DOMAIN_SHUTOFF => "shutoff", + virt::domain::VIR_DOMAIN_CRASHED => "crashed", + virt::domain::VIR_DOMAIN_PMSUSPENDED => "power management suspended", + _ => "unknown", + }; + name.to_string() +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs new file mode 100644 index 0000000..ad32e49 --- /dev/null +++ b/src/cmd/mod.rs @@ -0,0 +1,11 @@ +pub mod new; +pub use new::new; + +pub mod list; +pub use list::list; + +pub mod delete; +pub use delete::delete; + +pub mod cloud_init; +pub use cloud_init::{cloud_init, cloud_init_iso}; diff --git a/src/cmd/new.rs b/src/cmd/new.rs new file mode 100644 index 0000000..9930379 --- /dev/null +++ b/src/cmd/new.rs @@ -0,0 +1,47 @@ +use crate::cloudinit::CloudInitConfig; +use crate::image::VirtualMachineImage; +use crate::install::{virt_install, VirtInstallArgs}; +use crate::spec::Specification; + +use bytesize::GIB; +use log::info; +use std::net::TcpStream; + +const SSH_PORT: i32 = 22; + +pub fn new(spec: &Specification) -> anyhow::Result<()> { + info!("creating new VM {}", spec.name); + + info!("creating cloud-init config"); + let init = CloudInitConfig::from(&spec)?; + + info!( + "creating VM image {} from {}", + spec.image.display(), + spec.base.display() + ); + let image = VirtualMachineImage::new_from_base(&spec.base, &spec.image)?; + + info!("resizing image to {} GiB", spec.image_size_gib); + image.resize(spec.image_size_gib * GIB)?; + + info!("creating VM"); + let mut args = VirtInstallArgs::new(&spec.name, &image, &init); + args.set_memory(spec.memory_mib); + args.set_vcpus(spec.cpus); + virt_install(&args)?; + + info!("waiting for {} to open its SSH port", spec.name); + wait_for_port(&spec.name, SSH_PORT)?; + + Ok(()) +} + +fn wait_for_port(name: &str, port: i32) -> anyhow::Result<()> { + let addr = format!("{}:{}", name, port); + loop { + if TcpStream::connect(&addr).is_ok() { + return Ok(()); + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9ba4a7f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,33 @@ +use log::debug; +use serde::Deserialize; +use std::default::Default; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Default, Debug, Deserialize)] +pub struct Configuration { + pub default_base_image: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigurationError { + #[error(transparent)] + IoError(#[from] std::io::Error), + + #[error(transparent)] + YamlError(#[from] serde_yaml::Error), +} + +impl Configuration { + pub fn from_file(filename: &Path) -> Result { + if filename.exists() { + debug!("reading configuration file {}", filename.display()); + let config = fs::read(filename)?; + let config: Configuration = serde_yaml::from_slice(&config)?; + debug!("config: {:#?}", config); + Ok(config) + } else { + Ok(Self::default()) + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 4cc1d15..b4c07e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ pub mod cloudinit; +pub mod cmd; +pub mod config; pub mod image; pub mod install; pub mod spec; diff --git a/src/spec.rs b/src/spec.rs index 2cb61f6..0a3d097 100644 --- a/src/spec.rs +++ b/src/spec.rs @@ -1,9 +1,13 @@ +use crate::config::Configuration; + +use log::debug; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::fs; +use std::path::{Path, PathBuf}; #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct Specification { +struct InputSpecification { pub name: String, #[serde(default)] @@ -18,6 +22,26 @@ pub struct Specification { pub ed25519_host_key: Option, pub ed25519_host_cert: Option, + pub base: Option, + pub image: PathBuf, + pub image_size_gib: u64, + pub memory_mib: u64, + pub cpus: u64, +} + +#[derive(Debug)] +pub struct Specification { + pub name: String, + pub ssh_keys: Vec, + 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: PathBuf, pub image: PathBuf, pub image_size_gib: u64, @@ -27,21 +51,71 @@ pub struct Specification { #[derive(Debug, thiserror::Error)] pub enum SpecificationError { + #[error("No base image or default base image specified: {0}")] + NoBaseImage(PathBuf), + + #[error("Failed to read SSH public key file {0}")] + SshKeyRead(PathBuf, #[source] std::io::Error), + #[error(transparent)] IoError(#[from] std::io::Error), #[error(transparent)] FromUtf8Error(#[from] std::string::FromUtf8Error), + + #[error(transparent)] + YamlError(#[from] serde_yaml::Error), } impl Specification { - pub fn ssh_keys(&self) -> Result { - let mut keys = String::new(); - for filename in self.ssh_key_files.iter() { - let key = std::fs::read(filename)?; - let key = String::from_utf8(key)?; - keys.push_str(&key); - } - Ok(keys) + pub fn from_file( + config: &Configuration, + filename: &Path, + ) -> Result { + debug!("reading specification from {}", filename.display()); + let spec = fs::read(filename)?; + let input: InputSpecification = serde_yaml::from_slice(&spec)?; + debug!("specification as read from file: {:#?}", input); + + let base = if let Some(base) = input.base { + base.to_path_buf() + } else if let Some(ref base) = config.default_base_image { + base.to_path_buf() + } else { + return Err(SpecificationError::NoBaseImage(filename.to_path_buf())); + }; + + let spec = Specification { + name: input.name.clone(), + ssh_keys: ssh_keys(&input.ssh_key_files)?, + rsa_host_key: input.rsa_host_key, + rsa_host_cert: input.rsa_host_cert, + dsa_host_key: input.dsa_host_key, + dsa_host_cert: input.dsa_host_cert, + ecdsa_host_key: input.ecdsa_host_key, + ecdsa_host_cert: input.ecdsa_host_cert, + ed25519_host_key: input.ed25519_host_key, + ed25519_host_cert: input.ed25519_host_cert, + base, + image: input.image.clone(), + image_size_gib: input.image_size_gib, + memory_mib: input.memory_mib, + cpus: input.cpus, + }; + + 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) } -- cgit v1.2.1