mirror of
https://github.com/CloudNebulaProject/vm-manager.git
synced 2026-04-10 13:20:41 +00:00
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:
parent
407baab42f
commit
38bc2fa6fb
12 changed files with 1419 additions and 0 deletions
85
Cargo.lock
generated
85
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
120
crates/vm-manager/src/provision.rs
Normal file
120
crates/vm-manager/src/provision.rs
Normal 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(())
|
||||||
|
}
|
||||||
719
crates/vm-manager/src/vmfile.rs
Normal file
719
crates/vm-manager/src/vmfile.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
60
crates/vmctl/src/commands/down.rs
Normal file
60
crates/vmctl/src/commands/down.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
95
crates/vmctl/src/commands/provision_cmd.rs
Normal file
95
crates/vmctl/src/commands/provision_cmd.rs
Normal 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(())
|
||||||
|
}
|
||||||
128
crates/vmctl/src/commands/reload.rs
Normal file
128
crates/vmctl/src/commands/reload.rs
Normal 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(())
|
||||||
|
}
|
||||||
149
crates/vmctl/src/commands/up.rs
Normal file
149
crates/vmctl/src/commands/up.rs
Normal 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(())
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue