From 38bc2fa6fb33426d4161dfe58515846c66c0d785 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Sat, 14 Feb 2026 20:48:12 +0100 Subject: [PATCH] Add VMFile.kdl declarative VM definitions with up/down/reload/provision commands Introduce a Vagrantfile-like declarative config format using KDL for defining multi-VM environments. Includes KDL parsing with validation, a provisioning engine (shell inline/script + file upload over SSH), and four new CLI commands for managing VM lifecycles from VMFile.kdl definitions. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 85 +++ Cargo.toml | 1 + crates/vm-manager/Cargo.toml | 1 + crates/vm-manager/src/error.rs | 33 + crates/vm-manager/src/lib.rs | 2 + crates/vm-manager/src/provision.rs | 120 ++++ crates/vm-manager/src/vmfile.rs | 719 +++++++++++++++++++++ crates/vmctl/src/commands/down.rs | 60 ++ crates/vmctl/src/commands/mod.rs | 26 + crates/vmctl/src/commands/provision_cmd.rs | 95 +++ crates/vmctl/src/commands/reload.rs | 128 ++++ crates/vmctl/src/commands/up.rs | 149 +++++ 12 files changed, 1419 insertions(+) create mode 100644 crates/vm-manager/src/provision.rs create mode 100644 crates/vm-manager/src/vmfile.rs create mode 100644 crates/vmctl/src/commands/down.rs create mode 100644 crates/vmctl/src/commands/provision_cmd.rs create mode 100644 crates/vmctl/src/commands/reload.rs create mode 100644 crates/vmctl/src/commands/up.rs diff --git a/Cargo.lock b/Cargo.lock index 514e251..7afc796 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -851,6 +851,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kdl" +version = "6.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e" +dependencies = [ + "miette", + "num", + "winnow", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1012,6 +1023,70 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2069,6 +2144,7 @@ dependencies = [ "dirs", "futures-util", "isobemak", + "kdl", "libc", "miette", "reqwest", @@ -2474,6 +2550,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index ed57d3c..ed471eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,3 +35,4 @@ tempfile = "3" futures-util = "0.3" zstd = "0.13" dirs = "6" +kdl = "6" diff --git a/crates/vm-manager/Cargo.toml b/crates/vm-manager/Cargo.toml index d1b47f3..f0565c8 100644 --- a/crates/vm-manager/Cargo.toml +++ b/crates/vm-manager/Cargo.toml @@ -22,6 +22,7 @@ tempfile.workspace = true futures-util.workspace = true zstd.workspace = true dirs.workspace = true +kdl.workspace = true # Optional pure-Rust ISO generation isobemak = { version = "0.2", optional = true } diff --git a/crates/vm-manager/src/error.rs b/crates/vm-manager/src/error.rs index 0875ec5..9205d0c 100644 --- a/crates/vm-manager/src/error.rs +++ b/crates/vm-manager/src/error.rs @@ -117,6 +117,39 @@ pub enum VmError { )] BackendNotAvailable { backend: String }, + #[error("VMFile not found at {}", path.display())] + #[diagnostic( + code(vm_manager::vmfile::not_found), + help("create a VMFile.kdl in the current directory or specify a path with --file") + )] + VmFileNotFound { path: PathBuf }, + + #[error("failed to parse VMFile at {location}: {detail}")] + #[diagnostic( + code(vm_manager::vmfile::parse_failed), + help("check VMFile.kdl syntax — see https://kdl.dev for the KDL specification") + )] + VmFileParseFailed { location: String, detail: String }, + + #[error("VMFile validation error in VM '{vm}': {detail}")] + #[diagnostic(code(vm_manager::vmfile::validation), help("{hint}"))] + VmFileValidation { + vm: String, + detail: String, + hint: String, + }, + + #[error("provisioning failed for VM '{vm}' at step {step}: {detail}")] + #[diagnostic( + code(vm_manager::provision::failed), + help("check the provisioner configuration and that the VM is reachable via SSH") + )] + ProvisionFailed { + vm: String, + step: usize, + detail: String, + }, + #[error(transparent)] #[diagnostic(code(vm_manager::io))] Io(#[from] std::io::Error), diff --git a/crates/vm-manager/src/lib.rs b/crates/vm-manager/src/lib.rs index b6f0b03..6f1eb09 100644 --- a/crates/vm-manager/src/lib.rs +++ b/crates/vm-manager/src/lib.rs @@ -2,9 +2,11 @@ pub mod backends; pub mod cloudinit; pub mod error; pub mod image; +pub mod provision; pub mod ssh; pub mod traits; pub mod types; +pub mod vmfile; // Re-export key types at crate root for convenience. pub use backends::RouterHypervisor; diff --git a/crates/vm-manager/src/provision.rs b/crates/vm-manager/src/provision.rs new file mode 100644 index 0000000..c71915f --- /dev/null +++ b/crates/vm-manager/src/provision.rs @@ -0,0 +1,120 @@ +use std::path::Path; + +use ssh2::Session; +use tracing::info; + +use crate::error::{Result, VmError}; +use crate::ssh; +use crate::vmfile::{FileProvision, ProvisionDef, ShellProvision, resolve_path}; + +/// Run all provision steps on an established SSH session. +pub fn run_provisions( + sess: &Session, + provisions: &[ProvisionDef], + base_dir: &Path, + vm_name: &str, +) -> Result<()> { + for (i, prov) in provisions.iter().enumerate() { + let step = i + 1; + match prov { + ProvisionDef::Shell(shell) => { + run_shell(sess, shell, base_dir, vm_name, step)?; + } + ProvisionDef::File(file) => { + run_file(sess, file, base_dir, vm_name, step)?; + } + } + } + Ok(()) +} + +fn run_shell( + sess: &Session, + shell: &ShellProvision, + base_dir: &Path, + vm_name: &str, + step: usize, +) -> Result<()> { + if let Some(ref cmd) = shell.inline { + info!(vm = %vm_name, step, cmd = %cmd, "running inline shell provision"); + let (stdout, stderr, exit_code) = + ssh::exec(sess, cmd).map_err(|e| VmError::ProvisionFailed { + vm: vm_name.into(), + step, + detail: format!("shell exec: {e}"), + })?; + + if exit_code != 0 { + return Err(VmError::ProvisionFailed { + vm: vm_name.into(), + step, + detail: format!( + "inline command exited with code {exit_code}\nstdout: {stdout}\nstderr: {stderr}" + ), + }); + } + info!(vm = %vm_name, step, "inline shell provision completed"); + } else if let Some(ref script_raw) = shell.script { + let local_path = resolve_path(script_raw, base_dir); + info!(vm = %vm_name, step, script = %local_path.display(), "running script provision"); + + let remote_path_str = format!("/tmp/vmctl-provision-{step}.sh"); + let remote_path = Path::new(&remote_path_str); + + // Upload the script + ssh::upload(sess, &local_path, remote_path).map_err(|e| VmError::ProvisionFailed { + vm: vm_name.into(), + step, + detail: format!("upload script: {e}"), + })?; + + // Make executable and run + let run_cmd = format!("chmod +x {remote_path_str} && {remote_path_str}"); + let (stdout, stderr, exit_code) = + ssh::exec(sess, &run_cmd).map_err(|e| VmError::ProvisionFailed { + vm: vm_name.into(), + step, + detail: format!("script exec: {e}"), + })?; + + if exit_code != 0 { + return Err(VmError::ProvisionFailed { + vm: vm_name.into(), + step, + detail: format!( + "script exited with code {exit_code}\nstdout: {stdout}\nstderr: {stderr}" + ), + }); + } + info!(vm = %vm_name, step, "script provision completed"); + } + Ok(()) +} + +fn run_file( + sess: &Session, + file: &FileProvision, + base_dir: &Path, + vm_name: &str, + step: usize, +) -> Result<()> { + let local_path = resolve_path(&file.source, base_dir); + let remote_path = Path::new(&file.destination); + + info!( + vm = %vm_name, + step, + source = %local_path.display(), + destination = %file.destination, + "running file provision" + ); + + ssh::upload(sess, &local_path, remote_path).map_err(|e| VmError::ProvisionFailed { + vm: vm_name.into(), + step, + detail: format!("file upload: {e}"), + })?; + + info!(vm = %vm_name, step, "file provision completed"); + Ok(()) +} diff --git a/crates/vm-manager/src/vmfile.rs b/crates/vm-manager/src/vmfile.rs new file mode 100644 index 0000000..e8ab242 --- /dev/null +++ b/crates/vm-manager/src/vmfile.rs @@ -0,0 +1,719 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use kdl::KdlDocument; +use tracing::info; + +use crate::cloudinit::build_cloud_config; +use crate::error::{Result, VmError}; +use crate::image::ImageManager; +use crate::types::{CloudInitConfig, NetworkConfig, SshConfig, VmSpec}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// A parsed VMFile containing one or more VM definitions. +#[derive(Debug, Clone)] +pub struct VmFile { + /// Directory containing the VMFile (used for relative path resolution). + pub base_dir: PathBuf, + /// Ordered list of VM definitions. + pub vms: Vec, +} + +/// A single VM definition from a VMFile. +#[derive(Debug, Clone)] +pub struct VmDef { + pub name: String, + pub image: ImageSource, + pub vcpus: u16, + pub memory_mb: u64, + pub disk_gb: Option, + pub network: NetworkDef, + pub cloud_init: Option, + pub ssh: Option, + pub provisions: Vec, +} + +/// Where to source the VM image from. +#[derive(Debug, Clone)] +pub enum ImageSource { + Local(String), + Url(String), +} + +/// Network mode as declared in the VMFile. +#[derive(Debug, Clone, Default)] +pub enum NetworkDef { + #[default] + User, + Tap { + bridge: String, + }, + None, +} + +/// Cloud-init configuration block. +#[derive(Debug, Clone)] +pub struct CloudInitDef { + pub hostname: Option, + pub ssh_key: Option, + pub user_data: Option, +} + +/// SSH connection configuration block. +#[derive(Debug, Clone)] +pub struct SshDef { + pub user: String, + pub private_key: String, +} + +/// A provisioning step. +#[derive(Debug, Clone)] +pub enum ProvisionDef { + Shell(ShellProvision), + File(FileProvision), +} + +#[derive(Debug, Clone)] +pub struct ShellProvision { + pub inline: Option, + pub script: Option, +} + +#[derive(Debug, Clone)] +pub struct FileProvision { + pub source: String, + pub destination: String, +} + +// --------------------------------------------------------------------------- +// Path helpers +// --------------------------------------------------------------------------- + +/// Expand `~` at the start of a path to the user's home directory. +pub fn expand_tilde(s: &str) -> PathBuf { + if let Some(rest) = s.strip_prefix("~/") { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/root")) + .join(rest) + } else if s == "~" { + dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")) + } else { + PathBuf::from(s) + } +} + +/// Resolve a raw path string: expand tilde, then make relative paths absolute against `base_dir`. +pub fn resolve_path(raw: &str, base_dir: &Path) -> PathBuf { + let expanded = expand_tilde(raw); + if expanded.is_absolute() { + expanded + } else { + base_dir.join(expanded) + } +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +/// Find a VMFile.kdl — use the explicit path if given, otherwise look in the current directory. +pub fn discover(explicit: Option<&Path>) -> Result { + if let Some(path) = explicit { + let p = path.to_path_buf(); + if p.exists() { + Ok(p) + } else { + Err(VmError::VmFileNotFound { path: p }) + } + } else { + let p = PathBuf::from("VMFile.kdl"); + if p.exists() { + Ok(p) + } else { + Err(VmError::VmFileNotFound { path: p }) + } + } +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// Parse a VMFile.kdl at the given path into a `VmFile`. +pub fn parse(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| VmError::VmFileParseFailed { + location: path.display().to_string(), + detail: format!("could not read file: {e}"), + })?; + + let doc: KdlDocument = + content + .parse() + .map_err(|e: kdl::KdlError| VmError::VmFileParseFailed { + location: path.display().to_string(), + detail: e.to_string(), + })?; + + let base_dir = path + .parent() + .map(|p| { + if p.as_os_str().is_empty() { + PathBuf::from(".") + } else { + p.to_path_buf() + } + }) + .unwrap_or_else(|| PathBuf::from(".")); + + let mut vms = Vec::new(); + let mut seen_names = HashSet::new(); + + for node in doc.nodes() { + if node.name().to_string() != "vm" { + continue; + } + + let name = node + .get(0) + .and_then(|v| v.as_string()) + .ok_or_else(|| VmError::VmFileValidation { + vm: "".into(), + detail: "vm node must have a name argument".into(), + hint: "add a name: vm \"my-server\" { ... }".into(), + })? + .to_string(); + + if !seen_names.insert(name.clone()) { + return Err(VmError::VmFileValidation { + vm: name, + detail: "duplicate VM name".into(), + hint: "each vm must have a unique name".into(), + }); + } + + let children = node.children().ok_or_else(|| VmError::VmFileValidation { + vm: name.clone(), + detail: "vm node must have a body".into(), + hint: "add configuration inside braces: vm \"name\" { ... }".into(), + })?; + + let vm_def = parse_vm_def(&name, children)?; + vms.push(vm_def); + } + + if vms.is_empty() { + return Err(VmError::VmFileParseFailed { + location: path.display().to_string(), + detail: "no vm definitions found".into(), + }); + } + + Ok(VmFile { base_dir, vms }) +} + +fn parse_vm_def(name: &str, doc: &KdlDocument) -> Result { + // Image: local or URL + let local_image = doc + .get_arg("image") + .and_then(|v| v.as_string()) + .map(String::from); + let url_image = doc + .get_arg("image-url") + .and_then(|v| v.as_string()) + .map(String::from); + + let image = match (local_image, url_image) { + (Some(path), None) => ImageSource::Local(path), + (None, Some(url)) => ImageSource::Url(url), + (Some(_), Some(_)) => { + return Err(VmError::VmFileValidation { + vm: name.into(), + detail: "both image and image-url specified".into(), + hint: "use either image or image-url, not both".into(), + }); + } + (None, None) => { + return Err(VmError::VmFileValidation { + vm: name.into(), + detail: "no image specified".into(), + hint: "add image \"/path/to/image.qcow2\" or image-url \"https://...\"".into(), + }); + } + }; + + let vcpus = doc + .get_arg("vcpus") + .and_then(|v| v.as_integer()) + .map(|v| v as u16) + .unwrap_or(1); + + let memory_mb = doc + .get_arg("memory") + .and_then(|v| v.as_integer()) + .map(|v| v as u64) + .unwrap_or(1024); + + let disk_gb = doc + .get_arg("disk") + .and_then(|v| v.as_integer()) + .map(|v| v as u32); + + // Network + let network = if let Some(net_node) = doc.get("network") { + let net_type = net_node + .get(0) + .and_then(|v| v.as_string()) + .unwrap_or("user"); + match net_type { + "user" => NetworkDef::User, + "tap" => { + let bridge = net_node + .get("bridge") + .and_then(|v| v.as_string()) + .unwrap_or("br0") + .to_string(); + NetworkDef::Tap { bridge } + } + "none" => NetworkDef::None, + other => { + return Err(VmError::VmFileValidation { + vm: name.into(), + detail: format!("unknown network type: {other}"), + hint: "use \"user\", \"tap\", or \"none\"".into(), + }); + } + } + } else { + NetworkDef::default() + }; + + // Cloud-init + let cloud_init = if let Some(ci_node) = doc.get("cloud-init") { + let ci_doc = ci_node.children(); + let hostname = ci_doc + .and_then(|d| d.get_arg("hostname")) + .and_then(|v| v.as_string()) + .map(String::from); + let ssh_key = ci_doc + .and_then(|d| d.get_arg("ssh-key")) + .and_then(|v| v.as_string()) + .map(String::from); + let user_data = ci_doc + .and_then(|d| d.get_arg("user-data")) + .and_then(|v| v.as_string()) + .map(String::from); + + Some(CloudInitDef { + hostname, + ssh_key, + user_data, + }) + } else { + None + }; + + // SSH + let ssh = if let Some(ssh_node) = doc.get("ssh") { + let ssh_doc = ssh_node.children().ok_or_else(|| VmError::VmFileValidation { + vm: name.into(), + detail: "ssh block must have a body".into(), + hint: "add user and private-key inside: ssh { user \"vm\"; private-key \"~/.ssh/id_ed25519\" }".into(), + })?; + let user = ssh_doc + .get_arg("user") + .and_then(|v| v.as_string()) + .unwrap_or("vm") + .to_string(); + let private_key = ssh_doc + .get_arg("private-key") + .and_then(|v| v.as_string()) + .ok_or_else(|| VmError::VmFileValidation { + vm: name.into(), + detail: "ssh block requires private-key".into(), + hint: "add: private-key \"~/.ssh/id_ed25519\"".into(), + })? + .to_string(); + Some(SshDef { user, private_key }) + } else { + None + }; + + // Provisions + let mut provisions = Vec::new(); + for node in doc.nodes() { + if node.name().to_string() != "provision" { + continue; + } + let ptype = node.get(0).and_then(|v| v.as_string()).unwrap_or("shell"); + + let prov_doc = node.children().ok_or_else(|| VmError::VmFileValidation { + vm: name.into(), + detail: "provision block must have a body".into(), + hint: "add content inside: provision \"shell\" { inline \"...\" }".into(), + })?; + + match ptype { + "shell" => { + let inline = prov_doc + .get_arg("inline") + .and_then(|v| v.as_string()) + .map(String::from); + let script = prov_doc + .get_arg("script") + .and_then(|v| v.as_string()) + .map(String::from); + + if inline.is_none() && script.is_none() { + return Err(VmError::VmFileValidation { + vm: name.into(), + detail: "shell provision requires inline or script".into(), + hint: "add: inline \"command\" or script \"./setup.sh\"".into(), + }); + } + if inline.is_some() && script.is_some() { + return Err(VmError::VmFileValidation { + vm: name.into(), + detail: "shell provision cannot have both inline and script".into(), + hint: "use either inline or script, not both".into(), + }); + } + + provisions.push(ProvisionDef::Shell(ShellProvision { inline, script })); + } + "file" => { + let source = prov_doc + .get_arg("source") + .and_then(|v| v.as_string()) + .ok_or_else(|| VmError::VmFileValidation { + vm: name.into(), + detail: "file provision requires source".into(), + hint: "add: source \"./local-file.conf\"".into(), + })? + .to_string(); + let destination = prov_doc + .get_arg("destination") + .and_then(|v| v.as_string()) + .ok_or_else(|| VmError::VmFileValidation { + vm: name.into(), + detail: "file provision requires destination".into(), + hint: "add: destination \"/etc/app/config.conf\"".into(), + })? + .to_string(); + provisions.push(ProvisionDef::File(FileProvision { + source, + destination, + })); + } + other => { + return Err(VmError::VmFileValidation { + vm: name.into(), + detail: format!("unknown provision type: {other}"), + hint: "use \"shell\" or \"file\"".into(), + }); + } + } + } + + Ok(VmDef { + name: name.to_string(), + image, + vcpus, + memory_mb, + disk_gb, + network, + cloud_init, + ssh, + provisions, + }) +} + +// --------------------------------------------------------------------------- +// Resolve: VmDef -> VmSpec +// --------------------------------------------------------------------------- + +/// Resolve a `VmDef` into a ready-to-use `VmSpec` by downloading images, reading keys, etc. +pub async fn resolve(def: &VmDef, base_dir: &Path) -> Result { + // Resolve image + let image_path = match &def.image { + ImageSource::Local(raw) => { + let p = resolve_path(raw, base_dir); + if !p.exists() { + return Err(VmError::VmFileValidation { + vm: def.name.clone(), + detail: format!("image not found: {}", p.display()), + hint: "check the image path is correct and the file exists".into(), + }); + } + p + } + ImageSource::Url(url) => { + info!(vm = %def.name, url = %url, "downloading image"); + let mgr = ImageManager::new(); + mgr.pull(url, Some(&def.name)).await? + } + }; + + // Network + let network = match &def.network { + NetworkDef::User => NetworkConfig::User, + NetworkDef::Tap { bridge } => NetworkConfig::Tap { + bridge: bridge.clone(), + }, + NetworkDef::None => NetworkConfig::None, + }; + + // Cloud-init + let cloud_init = if let Some(ci) = &def.cloud_init { + if let Some(raw_path) = &ci.user_data { + // Raw user-data file + let p = resolve_path(raw_path, base_dir); + let data = tokio::fs::read(&p) + .await + .map_err(|e| VmError::VmFileValidation { + vm: def.name.clone(), + detail: format!("cannot read user-data at {}: {e}", p.display()), + hint: "check the user-data path".into(), + })?; + Some(CloudInitConfig { + user_data: data, + instance_id: Some(def.name.clone()), + hostname: ci.hostname.clone().or_else(|| Some(def.name.clone())), + }) + } else if let Some(key_raw) = &ci.ssh_key { + // Build cloud-config from SSH key + let key_path = resolve_path(key_raw, base_dir); + let pubkey = tokio::fs::read_to_string(&key_path).await.map_err(|e| { + VmError::VmFileValidation { + vm: def.name.clone(), + detail: format!("cannot read ssh-key at {}: {e}", key_path.display()), + hint: "check the ssh-key path".into(), + } + })?; + let hostname = ci.hostname.as_deref().unwrap_or(&def.name); + let ssh_user = def.ssh.as_ref().map(|s| s.user.as_str()).unwrap_or("vm"); + let (user_data, _meta) = + build_cloud_config(ssh_user, pubkey.trim(), &def.name, hostname); + Some(CloudInitConfig { + user_data, + instance_id: Some(def.name.clone()), + hostname: Some(hostname.to_string()), + }) + } else { + // cloud-init block with only hostname, no keys or user-data + None + } + } else { + None + }; + + // SSH config + let ssh = def.ssh.as_ref().map(|s| { + let key_path = resolve_path(&s.private_key, base_dir); + SshConfig { + user: s.user.clone(), + public_key: None, + private_key_path: Some(key_path), + private_key_pem: None, + } + }); + + Ok(VmSpec { + name: def.name.clone(), + image_path, + vcpus: def.vcpus, + memory_mb: def.memory_mb, + disk_gb: def.disk_gb, + network, + cloud_init, + ssh, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_minimal_vmfile() { + let kdl = r#" +vm "test" { + image "/tmp/test.qcow2" +} +"#; + let tmp = tempfile::NamedTempFile::with_suffix(".kdl").unwrap(); + std::fs::write(tmp.path(), kdl).unwrap(); + + let vmfile = parse(tmp.path()).unwrap(); + assert_eq!(vmfile.vms.len(), 1); + let vm = &vmfile.vms[0]; + assert_eq!(vm.name, "test"); + assert!(matches!(vm.image, ImageSource::Local(ref p) if p == "/tmp/test.qcow2")); + assert_eq!(vm.vcpus, 1); + assert_eq!(vm.memory_mb, 1024); + assert!(vm.disk_gb.is_none()); + assert!(matches!(vm.network, NetworkDef::User)); + assert!(vm.cloud_init.is_none()); + assert!(vm.ssh.is_none()); + assert!(vm.provisions.is_empty()); + } + + #[test] + fn parse_full_vmfile() { + let kdl = r#" +vm "web" { + image "/images/ubuntu.qcow2" + vcpus 2 + memory 2048 + disk 20 + network "tap" bridge="br0" + + cloud-init { + hostname "webhost" + ssh-key "~/.ssh/id_ed25519.pub" + } + + ssh { + user "admin" + private-key "~/.ssh/id_ed25519" + } + + provision "shell" { + inline "apt update" + } + + provision "file" { + source "./nginx.conf" + destination "/etc/nginx/nginx.conf" + } +} +"#; + let tmp = tempfile::NamedTempFile::with_suffix(".kdl").unwrap(); + std::fs::write(tmp.path(), kdl).unwrap(); + + let vmfile = parse(tmp.path()).unwrap(); + let vm = &vmfile.vms[0]; + assert_eq!(vm.name, "web"); + assert_eq!(vm.vcpus, 2); + assert_eq!(vm.memory_mb, 2048); + assert_eq!(vm.disk_gb, Some(20)); + assert!(matches!(vm.network, NetworkDef::Tap { ref bridge } if bridge == "br0")); + + let ci = vm.cloud_init.as_ref().unwrap(); + assert_eq!(ci.hostname.as_deref(), Some("webhost")); + assert_eq!(ci.ssh_key.as_deref(), Some("~/.ssh/id_ed25519.pub")); + + let ssh = vm.ssh.as_ref().unwrap(); + assert_eq!(ssh.user, "admin"); + assert_eq!(ssh.private_key, "~/.ssh/id_ed25519"); + + assert_eq!(vm.provisions.len(), 2); + assert!( + matches!(&vm.provisions[0], ProvisionDef::Shell(s) if s.inline.as_deref() == Some("apt update")) + ); + assert!(matches!(&vm.provisions[1], ProvisionDef::File(f) if f.source == "./nginx.conf")); + } + + #[test] + fn parse_multi_vm() { + let kdl = r#" +vm "alpha" { + image "/img/a.qcow2" +} + +vm "beta" { + image "/img/b.qcow2" + vcpus 4 + memory 4096 +} +"#; + let tmp = tempfile::NamedTempFile::with_suffix(".kdl").unwrap(); + std::fs::write(tmp.path(), kdl).unwrap(); + + let vmfile = parse(tmp.path()).unwrap(); + assert_eq!(vmfile.vms.len(), 2); + assert_eq!(vmfile.vms[0].name, "alpha"); + assert_eq!(vmfile.vms[1].name, "beta"); + assert_eq!(vmfile.vms[1].vcpus, 4); + } + + #[test] + fn parse_image_url() { + let kdl = r#" +vm "cloud" { + image-url "https://example.com/image.qcow2" +} +"#; + let tmp = tempfile::NamedTempFile::with_suffix(".kdl").unwrap(); + std::fs::write(tmp.path(), kdl).unwrap(); + + let vmfile = parse(tmp.path()).unwrap(); + assert!( + matches!(vmfile.vms[0].image, ImageSource::Url(ref u) if u == "https://example.com/image.qcow2") + ); + } + + #[test] + fn error_no_image() { + let kdl = r#" +vm "broken" { + vcpus 1 +} +"#; + let tmp = tempfile::NamedTempFile::with_suffix(".kdl").unwrap(); + std::fs::write(tmp.path(), kdl).unwrap(); + + let err = parse(tmp.path()).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("no image specified"), "got: {msg}"); + } + + #[test] + fn error_no_name() { + let kdl = r#" +vm { + image "/tmp/test.qcow2" +} +"#; + let tmp = tempfile::NamedTempFile::with_suffix(".kdl").unwrap(); + std::fs::write(tmp.path(), kdl).unwrap(); + + let err = parse(tmp.path()).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("name argument"), "got: {msg}"); + } + + #[test] + fn error_duplicate_names() { + let kdl = r#" +vm "dup" { + image "/tmp/a.qcow2" +} +vm "dup" { + image "/tmp/b.qcow2" +} +"#; + let tmp = tempfile::NamedTempFile::with_suffix(".kdl").unwrap(); + std::fs::write(tmp.path(), kdl).unwrap(); + + let err = parse(tmp.path()).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("duplicate"), "got: {msg}"); + } + + #[test] + fn expand_tilde_works() { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")); + let result = expand_tilde("~/foo/bar"); + assert_eq!(result, home.join("foo/bar")); + + let abs = expand_tilde("/absolute/path"); + assert_eq!(abs, PathBuf::from("/absolute/path")); + } +} diff --git a/crates/vmctl/src/commands/down.rs b/crates/vmctl/src/commands/down.rs new file mode 100644 index 0000000..0587b4e --- /dev/null +++ b/crates/vmctl/src/commands/down.rs @@ -0,0 +1,60 @@ +use std::path::PathBuf; +use std::time::Duration; + +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use vm_manager::{Hypervisor, RouterHypervisor}; + +use super::state; + +#[derive(Args)] +pub struct DownArgs { + /// Path to VMFile.kdl + #[arg(long)] + file: Option, + + /// Only bring down a specific VM by name + #[arg(long)] + name: Option, + + /// Destroy VMs instead of just stopping them + #[arg(long)] + destroy: bool, +} + +pub async fn run(args: DownArgs) -> Result<()> { + let path = vm_manager::vmfile::discover(args.file.as_deref()).into_diagnostic()?; + let vmfile = vm_manager::vmfile::parse(&path).into_diagnostic()?; + + let mut store = state::load_store().await?; + let hv = RouterHypervisor::new(None, None); + + for def in &vmfile.vms { + if let Some(ref filter) = args.name { + if &def.name != filter { + continue; + } + } + + if let Some(handle) = store.get(&def.name).cloned() { + if args.destroy { + store.remove(&def.name); + hv.destroy(handle).await.into_diagnostic()?; + state::save_store(&store).await?; + println!("VM '{}' destroyed", def.name); + } else { + let updated = hv + .stop(&handle, Duration::from_secs(30)) + .await + .into_diagnostic()?; + store.insert(def.name.clone(), updated); + state::save_store(&store).await?; + println!("VM '{}' stopped", def.name); + } + } else { + println!("VM '{}' not found in store — skipping", def.name); + } + } + + Ok(()) +} diff --git a/crates/vmctl/src/commands/mod.rs b/crates/vmctl/src/commands/mod.rs index ccaa6cd..a952b98 100644 --- a/crates/vmctl/src/commands/mod.rs +++ b/crates/vmctl/src/commands/mod.rs @@ -1,16 +1,21 @@ pub mod console; pub mod create; pub mod destroy; +pub mod down; pub mod image; pub mod list; +pub mod provision_cmd; +pub mod reload; pub mod ssh; pub mod start; pub mod state; pub mod status; pub mod stop; +pub mod up; use clap::{Parser, Subcommand}; use miette::Result; +use vm_manager::{NetworkConfig, VmHandle}; #[derive(Parser)] #[command(name = "vmctl", about = "Manage virtual machines", version)] @@ -43,6 +48,14 @@ enum Command { Resume(start::ResumeArgs), /// Manage VM images Image(image::ImageCommand), + /// Bring up VMs defined in VMFile.kdl + Up(up::UpArgs), + /// Bring down VMs defined in VMFile.kdl + Down(down::DownArgs), + /// Destroy and recreate VMs defined in VMFile.kdl + Reload(reload::ReloadArgs), + /// Re-run provisioners on running VMs from VMFile.kdl + Provision(provision_cmd::ProvisionArgs), } impl Cli { @@ -59,6 +72,19 @@ impl Cli { Command::Suspend(args) => start::run_suspend(args).await, Command::Resume(args) => start::run_resume(args).await, Command::Image(args) => image::run(args).await, + Command::Up(args) => up::run(args).await, + Command::Down(args) => down::run(args).await, + Command::Reload(args) => reload::run(args).await, + Command::Provision(args) => provision_cmd::run(args).await, } } } + +/// Determine the SSH port for a VM handle: use the forwarded host port for user-mode networking, +/// or 22 for all other network types. +fn ssh_port_for_handle(handle: &VmHandle) -> u16 { + match handle.network { + NetworkConfig::User => handle.ssh_host_port.unwrap_or(22), + _ => 22, + } +} diff --git a/crates/vmctl/src/commands/provision_cmd.rs b/crates/vmctl/src/commands/provision_cmd.rs new file mode 100644 index 0000000..c79473b --- /dev/null +++ b/crates/vmctl/src/commands/provision_cmd.rs @@ -0,0 +1,95 @@ +use std::path::PathBuf; +use std::time::Duration; + +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use vm_manager::{Hypervisor, RouterHypervisor, VmState}; + +use super::state; + +#[derive(Args)] +pub struct ProvisionArgs { + /// Path to VMFile.kdl + #[arg(long)] + file: Option, + + /// Only provision a specific VM by name + #[arg(long)] + name: Option, +} + +pub async fn run(args: ProvisionArgs) -> Result<()> { + let path = vm_manager::vmfile::discover(args.file.as_deref()).into_diagnostic()?; + let vmfile = vm_manager::vmfile::parse(&path).into_diagnostic()?; + + let store = state::load_store().await?; + let hv = RouterHypervisor::new(None, None); + + for def in &vmfile.vms { + if let Some(ref filter) = args.name { + if &def.name != filter { + continue; + } + } + + if def.provisions.is_empty() { + println!("VM '{}' has no provisioners — skipping", def.name); + continue; + } + + let handle = store.get(&def.name).ok_or_else(|| { + miette::miette!( + "VM '{}' not found in store — run `vmctl up` first", + def.name + ) + })?; + + let state = hv.state(handle).await.into_diagnostic()?; + if state != VmState::Running { + miette::bail!( + "VM '{}' is not running (state: {state}) — start it first", + def.name + ); + } + + let ssh_def = def.ssh.as_ref().ok_or_else(|| { + miette::miette!( + "VM '{}' has provisioners but no ssh block — add an ssh {{ }} section to VMFile.kdl", + def.name + ) + })?; + + let ip = hv.guest_ip(handle).await.into_diagnostic()?; + let port = super::ssh_port_for_handle(handle); + + let config = vm_manager::SshConfig { + user: ssh_def.user.clone(), + public_key: None, + private_key_path: Some(vm_manager::vmfile::resolve_path( + &ssh_def.private_key, + &vmfile.base_dir, + )), + private_key_pem: None, + }; + + println!("Provisioning VM '{}'...", def.name); + let sess = + vm_manager::ssh::connect_with_retry(&ip, port, &config, Duration::from_secs(120)) + .await + .into_diagnostic()?; + + let provisions = def.provisions.clone(); + let base_dir = vmfile.base_dir.clone(); + let name = def.name.clone(); + tokio::task::spawn_blocking(move || { + vm_manager::provision::run_provisions(&sess, &provisions, &base_dir, &name) + }) + .await + .into_diagnostic()? + .into_diagnostic()?; + + println!("VM '{}' provisioned", def.name); + } + + Ok(()) +} diff --git a/crates/vmctl/src/commands/reload.rs b/crates/vmctl/src/commands/reload.rs new file mode 100644 index 0000000..1b381bd --- /dev/null +++ b/crates/vmctl/src/commands/reload.rs @@ -0,0 +1,128 @@ +use std::path::PathBuf; +use std::time::Duration; + +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use tracing::info; +use vm_manager::vmfile::{ProvisionDef, SshDef}; +use vm_manager::{Hypervisor, RouterHypervisor}; + +use super::state; + +#[derive(Args)] +pub struct ReloadArgs { + /// Path to VMFile.kdl + #[arg(long)] + file: Option, + + /// Only reload a specific VM by name + #[arg(long)] + name: Option, + + /// Skip provisioning after reload + #[arg(long)] + no_provision: bool, +} + +pub async fn run(args: ReloadArgs) -> Result<()> { + let path = vm_manager::vmfile::discover(args.file.as_deref()).into_diagnostic()?; + let vmfile = vm_manager::vmfile::parse(&path).into_diagnostic()?; + + let mut store = state::load_store().await?; + let hv = RouterHypervisor::new(None, None); + + for def in &vmfile.vms { + if let Some(ref filter) = args.name { + if &def.name != filter { + continue; + } + } + + // Destroy existing if present + if let Some(handle) = store.remove(&def.name) { + info!(vm = %def.name, "destroying existing VM for reload"); + hv.destroy(handle).await.into_diagnostic()?; + state::save_store(&store).await?; + } + + // Resolve, prepare, start + info!(vm = %def.name, "creating and starting VM"); + let spec = vm_manager::vmfile::resolve(def, &vmfile.base_dir) + .await + .into_diagnostic()?; + + let handle = hv.prepare(&spec).await.into_diagnostic()?; + store.insert(def.name.clone(), handle.clone()); + state::save_store(&store).await?; + + let updated = hv.start(&handle).await.into_diagnostic()?; + store.insert(def.name.clone(), updated); + state::save_store(&store).await?; + println!("VM '{}' reloaded", def.name); + + // Provision + if !args.no_provision && !def.provisions.is_empty() { + run_provision_for_vm( + &hv, + &store, + &def.name, + &def.provisions, + def.ssh.as_ref(), + &vmfile.base_dir, + ) + .await?; + } + } + + Ok(()) +} + +async fn run_provision_for_vm( + hv: &RouterHypervisor, + store: &state::Store, + vm_name: &str, + provisions: &[ProvisionDef], + ssh_def: Option<&SshDef>, + base_dir: &std::path::Path, +) -> Result<()> { + let ssh_def = ssh_def.ok_or_else(|| { + miette::miette!( + "VM '{vm_name}' has provisioners but no ssh block — add an ssh {{ }} section to VMFile.kdl" + ) + })?; + + let handle = store + .get(vm_name) + .ok_or_else(|| miette::miette!("VM '{vm_name}' not found in store"))?; + + let ip = hv.guest_ip(handle).await.into_diagnostic()?; + let port = super::ssh_port_for_handle(handle); + + let config = vm_manager::SshConfig { + user: ssh_def.user.clone(), + public_key: None, + private_key_path: Some(vm_manager::vmfile::resolve_path( + &ssh_def.private_key, + base_dir, + )), + private_key_pem: None, + }; + + println!("Provisioning VM '{vm_name}'..."); + let sess = vm_manager::ssh::connect_with_retry(&ip, port, &config, Duration::from_secs(120)) + .await + .into_diagnostic()?; + + let provisions = provisions.to_vec(); + let base_dir = base_dir.to_path_buf(); + let name = vm_name.to_string(); + tokio::task::spawn_blocking(move || { + vm_manager::provision::run_provisions(&sess, &provisions, &base_dir, &name) + }) + .await + .into_diagnostic()? + .into_diagnostic()?; + + println!("VM '{vm_name}' provisioned"); + Ok(()) +} diff --git a/crates/vmctl/src/commands/up.rs b/crates/vmctl/src/commands/up.rs new file mode 100644 index 0000000..060f32f --- /dev/null +++ b/crates/vmctl/src/commands/up.rs @@ -0,0 +1,149 @@ +use std::path::PathBuf; +use std::time::Duration; + +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use tracing::info; +use vm_manager::vmfile::{ProvisionDef, SshDef}; +use vm_manager::{Hypervisor, RouterHypervisor, VmState}; + +use super::state; + +#[derive(Args)] +pub struct UpArgs { + /// Path to VMFile.kdl + #[arg(long)] + file: Option, + + /// Only bring up a specific VM by name + #[arg(long)] + name: Option, + + /// Skip provisioning + #[arg(long)] + no_provision: bool, +} + +pub async fn run(args: UpArgs) -> Result<()> { + let path = vm_manager::vmfile::discover(args.file.as_deref()).into_diagnostic()?; + let vmfile = vm_manager::vmfile::parse(&path).into_diagnostic()?; + + let mut store = state::load_store().await?; + let hv = RouterHypervisor::new(None, None); + + for def in &vmfile.vms { + if let Some(ref filter) = args.name { + if &def.name != filter { + continue; + } + } + + // Check if already in store + if let Some(handle) = store.get(&def.name) { + let state = hv.state(handle).await.into_diagnostic()?; + if state == VmState::Running { + println!("VM '{}' is already running — skipping", def.name); + continue; + } + + // Stopped → start + re-provision + info!(vm = %def.name, "starting existing VM"); + let updated = hv.start(handle).await.into_diagnostic()?; + store.insert(def.name.clone(), updated); + state::save_store(&store).await?; + println!("VM '{}' started", def.name); + + if !args.no_provision && !def.provisions.is_empty() { + run_provision_for_vm( + &hv, + &store, + def.name.as_str(), + &def.provisions, + def.ssh.as_ref(), + &vmfile.base_dir, + ) + .await?; + } + continue; + } + + // Not in store → resolve, prepare, start, provision + info!(vm = %def.name, "creating and starting VM"); + let spec = vm_manager::vmfile::resolve(def, &vmfile.base_dir) + .await + .into_diagnostic()?; + + let handle = hv.prepare(&spec).await.into_diagnostic()?; + store.insert(def.name.clone(), handle.clone()); + state::save_store(&store).await?; + + let updated = hv.start(&handle).await.into_diagnostic()?; + store.insert(def.name.clone(), updated); + state::save_store(&store).await?; + println!("VM '{}' created and started", def.name); + + if !args.no_provision && !def.provisions.is_empty() { + run_provision_for_vm( + &hv, + &store, + &def.name, + &def.provisions, + def.ssh.as_ref(), + &vmfile.base_dir, + ) + .await?; + } + } + + Ok(()) +} + +async fn run_provision_for_vm( + hv: &RouterHypervisor, + store: &state::Store, + vm_name: &str, + provisions: &[ProvisionDef], + ssh_def: Option<&SshDef>, + base_dir: &std::path::Path, +) -> Result<()> { + let ssh_def = ssh_def.ok_or_else(|| { + miette::miette!( + "VM '{vm_name}' has provisioners but no ssh block — add an ssh {{ }} section to VMFile.kdl" + ) + })?; + + let handle = store + .get(vm_name) + .ok_or_else(|| miette::miette!("VM '{vm_name}' not found in store"))?; + + let ip = hv.guest_ip(handle).await.into_diagnostic()?; + let port = super::ssh_port_for_handle(handle); + + let config = vm_manager::SshConfig { + user: ssh_def.user.clone(), + public_key: None, + private_key_path: Some(vm_manager::vmfile::resolve_path( + &ssh_def.private_key, + base_dir, + )), + private_key_pem: None, + }; + + println!("Provisioning VM '{vm_name}'..."); + let sess = vm_manager::ssh::connect_with_retry(&ip, port, &config, Duration::from_secs(120)) + .await + .into_diagnostic()?; + + let provisions = provisions.to_vec(); + let base_dir = base_dir.to_path_buf(); + let name = vm_name.to_string(); + tokio::task::spawn_blocking(move || { + vm_manager::provision::run_provisions(&sess, &provisions, &base_dir, &name) + }) + .await + .into_diagnostic()? + .into_diagnostic()?; + + println!("VM '{vm_name}' provisioned"); + Ok(()) +}