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 <noreply@anthropic.com>
This commit is contained in:
Till Wegmueller 2026-02-14 20:48:12 +01:00
parent 407baab42f
commit 38bc2fa6fb
No known key found for this signature in database
12 changed files with 1419 additions and 0 deletions

85
Cargo.lock generated
View file

@ -851,6 +851,17 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -1012,6 +1023,70 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -2069,6 +2144,7 @@ dependencies = [
"dirs", "dirs",
"futures-util", "futures-util",
"isobemak", "isobemak",
"kdl",
"libc", "libc",
"miette", "miette",
"reqwest", "reqwest",
@ -2474,6 +2550,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.6.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.46.0" version = "0.46.0"

View file

@ -35,3 +35,4 @@ tempfile = "3"
futures-util = "0.3" futures-util = "0.3"
zstd = "0.13" zstd = "0.13"
dirs = "6" dirs = "6"
kdl = "6"

View file

@ -22,6 +22,7 @@ tempfile.workspace = true
futures-util.workspace = true futures-util.workspace = true
zstd.workspace = true zstd.workspace = true
dirs.workspace = true dirs.workspace = true
kdl.workspace = true
# Optional pure-Rust ISO generation # Optional pure-Rust ISO generation
isobemak = { version = "0.2", optional = true } isobemak = { version = "0.2", optional = true }

View file

@ -117,6 +117,39 @@ pub enum VmError {
)] )]
BackendNotAvailable { backend: String }, 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)] #[error(transparent)]
#[diagnostic(code(vm_manager::io))] #[diagnostic(code(vm_manager::io))]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),

View file

@ -2,9 +2,11 @@ pub mod backends;
pub mod cloudinit; pub mod cloudinit;
pub mod error; pub mod error;
pub mod image; pub mod image;
pub mod provision;
pub mod ssh; pub mod ssh;
pub mod traits; pub mod traits;
pub mod types; pub mod types;
pub mod vmfile;
// Re-export key types at crate root for convenience. // Re-export key types at crate root for convenience.
pub use backends::RouterHypervisor; pub use backends::RouterHypervisor;

View file

@ -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(())
}

View file

@ -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<VmDef>,
}
/// 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<u32>,
pub network: NetworkDef,
pub cloud_init: Option<CloudInitDef>,
pub ssh: Option<SshDef>,
pub provisions: Vec<ProvisionDef>,
}
/// 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<String>,
pub ssh_key: Option<String>,
pub user_data: Option<String>,
}
/// 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<String>,
pub script: Option<String>,
}
#[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<PathBuf> {
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<VmFile> {
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: "<unknown>".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<VmDef> {
// 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<VmSpec> {
// 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"));
}
}

View file

@ -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<PathBuf>,
/// Only bring down a specific VM by name
#[arg(long)]
name: Option<String>,
/// 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(())
}

View file

@ -1,16 +1,21 @@
pub mod console; pub mod console;
pub mod create; pub mod create;
pub mod destroy; pub mod destroy;
pub mod down;
pub mod image; pub mod image;
pub mod list; pub mod list;
pub mod provision_cmd;
pub mod reload;
pub mod ssh; pub mod ssh;
pub mod start; pub mod start;
pub mod state; pub mod state;
pub mod status; pub mod status;
pub mod stop; pub mod stop;
pub mod up;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use miette::Result; use miette::Result;
use vm_manager::{NetworkConfig, VmHandle};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "vmctl", about = "Manage virtual machines", version)] #[command(name = "vmctl", about = "Manage virtual machines", version)]
@ -43,6 +48,14 @@ enum Command {
Resume(start::ResumeArgs), Resume(start::ResumeArgs),
/// Manage VM images /// Manage VM images
Image(image::ImageCommand), 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 { impl Cli {
@ -59,6 +72,19 @@ impl Cli {
Command::Suspend(args) => start::run_suspend(args).await, Command::Suspend(args) => start::run_suspend(args).await,
Command::Resume(args) => start::run_resume(args).await, Command::Resume(args) => start::run_resume(args).await,
Command::Image(args) => image::run(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,
}
}

View file

@ -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<PathBuf>,
/// Only provision a specific VM by name
#[arg(long)]
name: Option<String>,
}
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(())
}

View file

@ -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<PathBuf>,
/// Only reload a specific VM by name
#[arg(long)]
name: Option<String>,
/// 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(())
}

View file

@ -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<PathBuf>,
/// Only bring up a specific VM by name
#[arg(long)]
name: Option<String>,
/// 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(())
}