blob: f058c5d286e441b7cff7233b232f310cdbb874b6 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! CloudStack orchestrator extension for virtualOS on macOS.
//!
//! virtualOS (https://github.com/yep/virtualOS) runs macOS guests on Apple
//! silicon through Apple's Virtualization framework. It has no command line
//! interface, so this program manages its VM bundles directly and starts a VM
//! by launching a separate virtualOS process with the autostartVMBundlePath
//! user default set as a launch argument. Stopping a VM terminates that
//! process.
//!
//! The management server reaches the Mac over SSH (or runs locally when the
//! management server itself runs on the Mac). Instances are created by
//! cloning an existing virtualOS VM bundle, which is a copy-on-write copy on
//! APFS, after which the clone gets a new machine identifier and the CPU
//! count, memory and MAC address of the CloudStack instance. The bundle is
//! named after the CloudStack instance's internal name.
//!
//! Details (host details override extension details):
//! url Mac hostname/IP, or "localhost" to run locally
//! username SSH user owning the virtualOS VMs (logged in to the GUI)
//! password optional, SSH password (requires sshpass on the
//! management server; key authentication is preferred)
//! ssh_key optional, private key path on the management server
//! ssh_port optional, defaults to 22
//! verify_host_key optional, "true" (default) or "false"
//! app_path optional, defaults to /Applications/virtualOS.app,
//! falling back to a Spotlight lookup of the app bundle
//! vm_directory optional, directory holding the VM bundles; must be
//! the "VM files" directory configured in virtualOS,
//! defaults to its sandbox container's Documents folder
//! template_name optional, default VM bundle to clone (without .bundle)
//! network_mode optional, nat|bridged; when set it is applied to the
//! instance, otherwise the template's mode is kept
//! bridge_interface optional, macOS interface for bridged mode, e.g. en0
//! wait_timeout optional, seconds to wait for state changes (120)
//! start_in_background optional, "true" to launch virtualOS without
//! bringing its window to the front (default "false")
//!
//! Instance/template details:
//! template_name VM bundle to clone, overrides the host/extension value
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
use std::fs;
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
const DEFAULT_APP_PATH: &str = "/Applications/virtualOS.app";
const VIRTUALOS_BUNDLE_ID: &str = "com.github.yep.ios.virtualOS";
const VIRTUALOS_LOG_SUBSYSTEM: &str = "com.github.virtualOS";
const AUTOSTART_ARGUMENT: &str = "-autostartVMBundlePath";
const LOCAL_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
const DHCP_LEASES: &str = "/var/db/dhcpd_leases";
const REQUIRED_BUNDLE_FILES: [&str; 3] = ["HardwareModel", "AuxiliaryStorage", "Parameters.txt"];
const VM_STARTED_MESSAGE: &str = "vm started";
const START_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
type Result<T> = std::result::Result<T, String>;
fn default_vm_directory() -> String {
format!("~/Library/Containers/{VIRTUALOS_BUNDLE_ID}/Data/Documents")
}
fn network_type(mode: &str) -> Option<&'static str> {
match mode {
"nat" => Some("NAT"),
"bridged" => Some("Bridge"),
_ => None,
}
}
fn success_message(message: &str) -> Value {
json!({"status": "success", "message": message})
}
/// Lower case, zero padded form of a MAC address; macOS drops leading zeros in its DHCP leases.
fn normalize_mac(mac: &str) -> String {
mac.split(':')
.map(|octet| match u8::from_str_radix(octet.trim(), 16) {
Ok(value) => format!("{value:02x}"),
Err(_) => octet.trim().to_lowercase(),
})
.collect::<Vec<_>>()
.join(":")
}
/// Quote an argument for a POSIX shell, like Python's shlex.quote.
fn shell_quote(arg: &str) -> String {
let safe = |c: char| c.is_ascii_alphanumeric() || "@%+=:,./_-".contains(c);
if !arg.is_empty() && arg.chars().all(safe) {
arg.to_string()
} else {
format!("'{}'", arg.replace('\'', "'\"'\"'"))
}
}
/// VZMacMachineIdentifier.dataRepresentation: a binary plist holding {"ECID": <integer>}.
fn machine_identifier(ecid: u64) -> Vec<u8> {
let mut plist = b"bplist00".to_vec();
// object 0 at offset 8: dict with one entry, key object 1, value object 2
plist.extend_from_slice(&[0xd1, 0x01, 0x02]);
// object 1 at offset 11: ASCII string of length 4
plist.push(0x54);
plist.extend_from_slice(b"ECID");
// object 2 at offset 16: 8 byte integer
plist.push(0x13);
plist.extend_from_slice(&ecid.to_be_bytes());
let offset_table = plist.len() as u64;
plist.extend_from_slice(&[8, 11, 16]);
// trailer: 6 unused bytes, offset size, object reference size, object count, top object, offset table offset
plist.extend_from_slice(&[0; 6]);
plist.extend_from_slice(&[1, 1]);
plist.extend_from_slice(&3u64.to_be_bytes());
plist.extend_from_slice(&0u64.to_be_bytes());
plist.extend_from_slice(&offset_table.to_be_bytes());
plist
}
fn random_ecid() -> Result<u64> {
let mut bytes = [0u8; 8];
fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(&mut bytes))
.map_err(|e| format!("Failed to read random data: {e}"))?;
Ok(u64::from_be_bytes(bytes) >> 1)
}
/// IP addresses leased to the given (normalized) MAC address in a macOS dhcpd_leases file.
fn leased_addresses(leases: &str, mac: &str) -> Vec<String> {
let mut addresses = Vec::new();
for block in leases.split('{').skip(1) {
let block = block.split('}').next().unwrap_or("");
let fields: BTreeMap<&str, &str> = block
.lines()
.filter_map(|line| line.trim().split_once('='))
.collect();
let hw_address = fields.get("hw_address").map(|a| a.rsplit(',').next().unwrap_or("")).unwrap_or("");
if let Some(ip) = fields.get("ip_address") {
if !hw_address.is_empty() && normalize_mac(hw_address) == mac {
addresses.push(ip.to_string());
}
}
}
addresses
}
/// Text of a JSON value that may be a string or a number.
fn value_text(value: Option<&Value>) -> String {
match value {
Some(Value::String(s)) => s.clone(),
Some(Value::Null) | None => String::new(),
Some(other) => other.to_string(),
}
}
fn value_u64(value: Option<&Value>) -> Option<u64> {
match value {
Some(Value::Number(n)) => n.as_u64(),
Some(Value::String(s)) => s.trim().parse().ok(),
_ => None,
}
}
struct Config {
url: String,
username: String,
password: String,
ssh_key: String,
ssh_port: String,
verify_host_key: bool,
app_path: String,
vm_directory: String,
network_mode: String,
bridge_interface: String,
wait_timeout: u64,
start_in_background: bool,
template_name: String,
local: bool,
vmname: String,
cpus: Option<u64>,
memory: Option<u64>,
macs: Vec<String>,
}
impl Config {
fn parse(json_data: &Value) -> Result<Config> {
let empty = Map::new();
let section = |value: Option<&Value>| value.and_then(Value::as_object).unwrap_or(&empty).clone();
let external = section(json_data.get("externaldetails"));
let extension = section(external.get("extension"));
let host = section(external.get("host"));
let vm = section(external.get("virtualmachine"));
let detail = |name: &str, default: &str| {
[host.get(name), extension.get(name)]
.into_iter()
.map(value_text)
.find(|v| !v.is_empty())
.unwrap_or_else(|| default.to_string())
};
let flag = |name: &str, default: &str| detail(name, default).to_lowercase() == "true";
let url = detail("url", "");
let username = detail("username", "");
if url.is_empty() {
return Err("Missing required field in JSON: url".into());
}
let network_mode = detail("network_mode", "").to_lowercase();
if !network_mode.is_empty() && network_type(&network_mode).is_none() {
return Err(format!("Invalid network_mode '{network_mode}', expected one of nat, bridged"));
}
let local = LOCAL_HOSTS.contains(&url.to_lowercase().as_str()) && username.is_empty();
if !local && username.is_empty() {
return Err("Missing required field in JSON: username".into());
}
let wait_timeout = detail("wait_timeout", "120")
.trim()
.parse()
.map_err(|_| "Error parsing JSON: invalid wait_timeout".to_string())?;
let template_name = Some(value_text(vm.get("template_name")))
.filter(|t| !t.is_empty())
.unwrap_or_else(|| detail("template_name", ""));
let vm_details = section(json_data.get("cloudstack.vm.details"));
let mut nics: Vec<&Value> = vm_details.get("nics").and_then(Value::as_array).map(|n| n.iter().collect()).unwrap_or_default();
nics.sort_by_key(|nic| nic.get("deviceId").and_then(Value::as_i64).unwrap_or(0));
let macs = nics
.iter()
.map(|nic| value_text(nic.get("mac")))
.filter(|mac| !mac.is_empty())
.collect();
Ok(Config {
url,
username,
password: detail("password", ""),
ssh_key: detail("ssh_key", ""),
ssh_port: detail("ssh_port", "22"),
verify_host_key: flag("verify_host_key", "true"),
start_in_background: flag("start_in_background", "false"),
app_path: detail("app_path", ""),
vm_directory: detail("vm_directory", &default_vm_directory()).trim_end_matches('/').to_string(),
network_mode,
bridge_interface: detail("bridge_interface", ""),
wait_timeout,
template_name,
local,
vmname: value_text(vm_details.get("name")),
cpus: value_u64(vm_details.get("cpus")),
memory: value_u64(vm_details.get("minRam")),
macs,
})
}
}
struct VirtualOSManager {
config: Config,
}
impl VirtualOSManager {
fn ssh_command(&self, remote_argv: &[String]) -> Vec<String> {
let c = &self.config;
let mut cmd: Vec<String> = vec![
"ssh".into(),
"-o".into(), "ConnectTimeout=15".into(),
"-o".into(), format!("StrictHostKeyChecking={}", if c.verify_host_key { "yes" } else { "no" }),
"-p".into(), c.ssh_port.clone(),
];
if !c.verify_host_key {
cmd.extend(["-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR"].map(String::from));
}
if !c.ssh_key.is_empty() {
cmd.extend(["-i".to_string(), c.ssh_key.clone()]);
}
if !c.password.is_empty() {
cmd.splice(0..0, ["sshpass".to_string(), "-e".to_string()]);
cmd.extend(["-o", "BatchMode=no"].map(String::from));
} else {
cmd.extend(["-o", "BatchMode=yes"].map(String::from));
}
cmd.push(format!("{}@{}", c.username, c.url));
cmd.push("--".into());
cmd.push(remote_argv.iter().map(|a| shell_quote(a)).collect::<Vec<_>>().join(" "));
cmd
}
fn run_with_input(&self, argv: &[String], stdin: Option<&[u8]>) -> Result<String> {
let cmd = if self.config.local { argv.to_vec() } else { self.ssh_command(argv) };
let mut command = Command::new(&cmd[0]);
command
.args(&cmd[1..])
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if !self.config.local && !self.config.password.is_empty() {
command.env("SSHPASS", &self.config.password);
}
let mut child = command.spawn().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => format!("Command not found: {}", cmd[0]),
_ => format!("Failed to run {}: {e}", cmd[0]),
})?;
if let (Some(input), Some(mut pipe)) = (stdin, child.stdin.take()) {
let input = input.to_vec();
thread::spawn(move || pipe.write_all(&input));
}
let pid = child.id();
let (sender, receiver) = mpsc::channel();
thread::spawn(move || sender.send(child.wait_with_output()));
let timeout = Duration::from_secs(self.config.wait_timeout + 30);
let output = match receiver.recv_timeout(timeout) {
Ok(output) => output.map_err(|e| format!("Failed to run {}: {e}", cmd[0]))?,
Err(_) => {
let _ = Command::new("kill").args(["-KILL", &pid.to_string()]).status();
return Err(format!("Timed out running: {}", argv.join(" ")));
}
};
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let message = if stderr.trim().is_empty() { stdout.trim() } else { stderr.trim() };
return Err(if message.is_empty() {
format!("'{}' exited with {}", argv.join(" "), output.status.code().unwrap_or(-1))
} else {
message.to_string()
});
}
Ok(stdout)
}
fn run(&self, argv: &[&str]) -> Result<String> {
self.run_with_input(&argv.iter().map(|a| a.to_string()).collect::<Vec<_>>(), None)
}
fn sh_with_input(&self, script: &str, args: &[&str], stdin: Option<&[u8]>) -> Result<String> {
let mut argv = vec!["sh".to_string(), "-c".into(), script.into(), "sh".into()];
argv.extend(args.iter().map(|a| a.to_string()));
self.run_with_input(&argv, stdin)
}
fn sh(&self, script: &str, args: &[&str]) -> Result<String> {
self.sh_with_input(script, args, None)
}
fn vm_directory(&mut self) -> Result<String> {
let directory = &self.config.vm_directory;
if directory == "~" || directory.starts_with("~/") {
let home = self.sh("printf %s \"$HOME\"", &[])?.trim().to_string();
self.config.vm_directory = format!("{home}{}", &self.config.vm_directory[1..]);
}
Ok(self.config.vm_directory.clone())
}
fn bundle_path(&mut self, name: Option<&str>) -> Result<String> {
let name = name.map(String::from).unwrap_or_else(|| self.config.vmname.clone());
Ok(format!("{}/{name}.bundle", self.vm_directory()?))
}
fn app_path(&mut self) -> Result<String> {
if self.config.app_path.is_empty() {
let script = format!(
"[ -d \"$1\" ] && echo \"$1\" && exit 0; mdfind \"kMDItemCFBundleIdentifier == '{VIRTUALOS_BUNDLE_ID}'\" | head -n 1"
);
let path = self.sh(&script, &[DEFAULT_APP_PATH]).map(|p| p.trim().to_string()).unwrap_or_default();
if path.is_empty() {
return Err("virtualOS not found on the host, set the app_path detail".into());
}
self.config.app_path = path;
}
Ok(self.config.app_path.clone())
}
fn list_bundles(&mut self) -> Result<Vec<String>> {
let script = "cd \"$1\" 2>/dev/null || exit 0; for b in *.bundle; do [ -d \"$b\" ] && echo \"${b%.bundle}\"; done; exit 0";
let directory = self.vm_directory()?;
Ok(self.sh(script, &[&directory])?.lines().filter(|l| !l.is_empty()).map(String::from).collect())
}
fn bundle_not_found(&mut self) -> Result<String> {
let directory = self.vm_directory()?;
Ok(format!("VM bundle '{}' not found in {directory}", self.config.vmname))
}
fn bundle_exists(&mut self) -> Result<bool> {
let name = self.config.vmname.clone();
Ok(self.list_bundles()?.contains(&name))
}
/// Map bundle path to the pids of the virtualOS processes autostarting it.
fn running_vms(&self) -> Result<BTreeMap<String, Vec<String>>> {
let executable = "/Contents/MacOS/virtualOS ";
let marker = format!(" {AUTOSTART_ARGUMENT} ");
let mut running: BTreeMap<String, Vec<String>> = BTreeMap::new();
for line in self.run(&["ps", "-axww", "-o", "pid=,command="])?.lines() {
let (pid, command) = line.trim().split_once(' ').unwrap_or((line.trim(), ""));
if command.contains(executable) {
if let Some((_, bundle)) = command.split_once(&marker) {
running.entry(bundle.trim().to_string()).or_default().push(pid.to_string());
}
}
}
Ok(running)
}
fn vm_pids(&mut self, name: Option<&str>) -> Result<Vec<String>> {
let bundle = self.bundle_path(name)?;
Ok(self.running_vms()?.remove(&bundle).unwrap_or_default())
}
fn require_vmname(&self) -> Result<()> {
let name = &self.config.vmname;
if name.is_empty() {
return Err("Missing required field in JSON: cloudstack.vm.details.name".into());
}
if name.contains('/') || name.starts_with('.') {
return Err(format!("Invalid instance name '{name}'"));
}
Ok(())
}
fn read_parameters(&self, bundle: &str) -> Result<Map<String, Value>> {
let output = self.run(&["cat", &format!("{bundle}/Parameters.txt")])?;
match serde_json::from_str(&output) {
Ok(Value::Object(parameters)) => Ok(parameters),
_ => Err(format!("Failed to parse {bundle}/Parameters.txt")),
}
}
fn write_file(&self, path: &str, content: &[u8]) -> Result<()> {
self.sh_with_input("cat > \"$1\"", &[path], Some(content)).map(|_| ())
}
fn write_machine_identifier(&self, bundle: &str) -> Result<()> {
self.write_file(&format!("{bundle}/MachineIdentifier"), &machine_identifier(random_ecid()?))
}
/// virtualOS matches the bridge by its display name, e.g. "Wi-Fi (en0)".
fn bridge_description(&self) -> Result<String> {
let interface = &self.config.bridge_interface;
if interface.is_empty() || interface.contains('(') {
return Ok(interface.clone());
}
let mut port: Option<&str> = None;
let output = self.run(&["networksetup", "-listallhardwareports"])?;
for line in output.lines() {
if let Some(name) = line.strip_prefix("Hardware Port: ") {
port = Some(name.trim());
} else if let (Some(device), Some(port)) = (line.strip_prefix("Device: "), port) {
if device.trim() == interface {
return Ok(format!("{port} ({interface})"));
}
}
}
Ok(interface.clone())
}
fn configure(&self, bundle: &str) -> Result<()> {
let mut parameters = self.read_parameters(bundle)?;
let cpus = self.config.cpus.unwrap_or(1);
let memory_gb = self.config.memory.unwrap_or(0).div_ceil(1024 * 1024 * 1024).max(1);
let current = |parameters: &Map<String, Value>, key: &str, default: u64| value_u64(parameters.get(key)).unwrap_or(default);
let cpu_max = current(&parameters, "cpuCountMax", cpus).max(cpus);
let cpu_min = current(&parameters, "cpuCountMin", cpus).min(cpus);
let memory_max = current(&parameters, "memorySizeInGBMax", memory_gb).max(memory_gb);
let memory_min = current(&parameters, "memorySizeInGBMin", memory_gb).min(memory_gb);
parameters.insert("cpuCount".into(), json!(cpus));
parameters.insert("cpuCountMax".into(), json!(cpu_max));
parameters.insert("cpuCountMin".into(), json!(cpu_min));
parameters.insert("memorySizeInGB".into(), json!(memory_gb));
parameters.insert("memorySizeInGBMax".into(), json!(memory_max));
parameters.insert("memorySizeInGBMin".into(), json!(memory_min));
parameters.insert("installFinished".into(), json!(true));
if let Some(mac) = self.config.macs.first() {
parameters.insert("macAddress".into(), json!(normalize_mac(mac)));
}
if let Some(network) = network_type(&self.config.network_mode) {
parameters.insert("networkType".into(), json!(network));
if self.config.network_mode == "bridged" {
parameters.insert("networkBridge".into(), json!(self.bridge_description()?));
}
}
let content = serde_json::to_string_pretty(&parameters).map_err(|e| e.to_string())?;
self.write_file(&format!("{bundle}/Parameters.txt"), content.as_bytes())
}
/// Messages virtualOS logged for the given process, oldest first.
fn virtualos_messages(&self, pid: &str) -> Result<Vec<String>> {
let predicate = format!("subsystem == \"{VIRTUALOS_LOG_SUBSYSTEM}\" AND processID == {pid}");
let output = self.run(&["/usr/bin/log", "show", "--last", "5m", "--style", "compact", "--predicate", &predicate])?;
let mut messages: Vec<String> = Vec::new();
for line in output.lines().filter(|l| l.contains(&format!("[{VIRTUALOS_LOG_SUBSYSTEM}:"))) {
let message = line.rsplit_once("] ").map(|(_, m)| m.trim().to_string()).unwrap_or_default();
if !messages.contains(&message) {
messages.push(message);
}
}
Ok(messages)
}
/// Wait until virtualOS logs that the VM started; the failure reason is often redacted as <private>.
fn wait_for_vm_started(&mut self, pid: &str) -> Result<()> {
let vmname = self.config.vmname.clone();
let deadline = Instant::now() + START_CHECK_TIMEOUT;
let mut messages = Vec::new();
loop {
if let Ok(logged) = self.virtualos_messages(pid) {
messages = logged;
}
if messages.iter().any(|m| m == VM_STARTED_MESSAGE) {
return Ok(());
}
let errors: Vec<&String> = messages.iter().filter(|m| m.starts_with("Error")).collect();
if !errors.is_empty() || !self.vm_pids(None)?.contains(&pid.to_string()) || Instant::now() > deadline {
let reason = if errors.is_empty() { messages.join("; ") } else { errors.iter().map(|e| e.as_str()).collect::<Vec<_>>().join("; ") };
self.stop_vm(None)?;
return Err(format!("virtualOS did not start {vmname}: {reason}"));
}
thread::sleep(Duration::from_secs(2));
}
}
fn start_vm(&mut self) -> Result<()> {
let bundle = self.bundle_path(None)?;
if !self.vm_pids(None)?.is_empty() {
if !self.bundle_holders(&bundle)?.is_empty() {
return Ok(());
}
// the guest shut itself down, the virtualOS window stays open: close it before starting again
self.stop_vm(None)?;
}
let app = self.app_path()?;
let mut open = vec!["open", "-n"];
if self.config.start_in_background {
open.push("-g");
}
open.extend(["-a", &app, "--args", AUTOSTART_ARGUMENT, &bundle]);
self.run(&open)?;
let vmname = self.config.vmname.clone();
let deadline = Instant::now() + Duration::from_secs(self.config.wait_timeout);
while self.vm_pids(None)?.is_empty() {
if Instant::now() > deadline {
return Err(format!("Timed out waiting for virtualOS to start {vmname}"));
}
thread::sleep(Duration::from_secs(1));
}
let pids = self.vm_pids(None)?;
let Some(pid) = pids.first() else {
return Err(format!("virtualOS exited while starting {vmname}"));
};
let pid = pid.clone();
self.wait_for_vm_started(&pid)
}
fn kill(&self, signal: &str, pids: &[String]) -> Result<()> {
let mut argv = vec!["kill", signal];
argv.extend(pids.iter().map(String::as_str));
self.run(&argv).map(|_| ())
}
fn stop_vm(&mut self, name: Option<&str>) -> Result<()> {
let pids = self.vm_pids(name)?;
if pids.is_empty() {
return Ok(());
}
self.kill("-TERM", &pids)?;
let deadline = Instant::now() + Duration::from_secs(self.config.wait_timeout);
loop {
let pids = self.vm_pids(name)?;
if pids.is_empty() {
break;
}
if Instant::now() > deadline {
self.kill("-KILL", &pids)?;
thread::sleep(Duration::from_secs(2));
break;
}
thread::sleep(Duration::from_secs(1));
}
if !self.vm_pids(name)?.is_empty() {
return Err(format!("Failed to stop {}", name.unwrap_or(&self.config.vmname)));
}
self.wait_for_bundle_release(name)
}
/// The Virtualization framework's VM process can keep the disk open after virtualOS exits;
/// starting the VM again before it lets go fails.
fn wait_for_bundle_release(&mut self, name: Option<&str>) -> Result<()> {
let bundle = self.bundle_path(name)?;
let deadline = Instant::now() + Duration::from_secs(self.config.wait_timeout);
loop {
let holders = self.bundle_holders(&bundle)?;
if holders.is_empty() {
return Ok(());
}
if Instant::now() > deadline {
return Err(format!("{bundle} is still in use by process(es) {}", holders.join(", ")));
}
thread::sleep(Duration::from_secs(1));
}
}
/// Pids of the processes holding files of the bundle open; while the guest runs, that is the
/// Virtualization framework's VM process.
fn bundle_holders(&self, bundle: &str) -> Result<Vec<String>> {
let output = self.sh("lsof -t +d \"$1\" 2>/dev/null; exit 0", &[bundle])?;
let mut pids: Vec<String> = output.split_whitespace().map(String::from).collect();
pids.dedup();
Ok(pids)
}
fn remove_bundle(&mut self) -> Result<()> {
let bundle = self.bundle_path(None)?;
if self.config.vmname.is_empty() || !bundle.ends_with(".bundle") {
return Err(format!("Refusing to remove '{bundle}'"));
}
self.run(&["rm", "-rf", &bundle]).map(|_| ())
}
fn create(&mut self) -> Result<Value> {
self.require_vmname()?;
let vm_name = self.config.vmname.clone();
let template = self.config.template_name.clone();
if template.is_empty() {
return Err("Missing required field in JSON: template_name".into());
}
if self.config.cpus.is_none() || self.config.memory.is_none() {
return Err("Missing CPU or memory in cloudstack.vm.details".into());
}
if self.config.macs.len() > 1 {
return Err("virtualOS supports a single network interface per VM".into());
}
let bundles = self.list_bundles()?;
if !bundles.contains(&template) {
return Err(format!("Template VM bundle '{template}' not found in {}", self.vm_directory()?));
}
if bundles.contains(&vm_name) {
return Err(format!("A virtualOS VM named '{vm_name}' already exists"));
}
let template_bundle = self.bundle_path(Some(&template))?;
let mut args = vec![template_bundle.as_str()];
args.extend(REQUIRED_BUNDLE_FILES);
let missing = self.sh("cd \"$1\" && shift && for f; do [ -e \"$f\" ] || echo \"$f\"; done; exit 0", &args)?;
let missing: Vec<&str> = missing.split_whitespace().collect();
if !missing.is_empty() {
return Err(format!("Template VM bundle '{template}' is incomplete, missing: {}", missing.join(", ")));
}
if !self.vm_pids(Some(&template))?.is_empty() {
return Err(format!("Template VM '{template}' is running, stop it before cloning"));
}
let bundle = self.bundle_path(None)?;
// -c clones the files on APFS, plain copy elsewhere
self.sh("cp -cR \"$1\" \"$2\" 2>/dev/null || { rm -rf \"$2\"; cp -R \"$1\" \"$2\"; }", &[&template_bundle, &bundle])?;
let result = self
.write_machine_identifier(&bundle)
.and_then(|_| self.configure(&bundle))
.and_then(|_| self.start_vm());
if let Err(e) = result {
let _ = self.stop_vm(None).and_then(|_| self.remove_bundle());
return Err(e);
}
Ok(success_message("Instance created"))
}
fn start(&mut self) -> Result<Value> {
self.require_vmname()?;
if !self.bundle_exists()? {
return Err(self.bundle_not_found()?);
}
self.apply_offering()?;
self.start_vm()?;
Ok(success_message("Instance started"))
}
/// Write the instance's current CPU count, memory and MAC address to a stopped VM,
/// so changed service offerings take effect on the next start.
fn apply_offering(&mut self) -> Result<()> {
if self.config.cpus.is_none() || self.config.memory.is_none() || !self.vm_pids(None)?.is_empty() {
return Ok(());
}
let bundle = self.bundle_path(None)?;
self.configure(&bundle)
}
fn stop(&mut self) -> Result<Value> {
self.require_vmname()?;
self.stop_vm(None)?;
Ok(success_message("Instance stopped"))
}
fn reboot(&mut self) -> Result<Value> {
self.require_vmname()?;
self.stop_vm(None)?;
self.apply_offering()?;
self.start_vm()?;
Ok(success_message("Instance rebooted"))
}
fn delete(&mut self) -> Result<Value> {
self.require_vmname()?;
self.stop_vm(None)?;
if self.bundle_exists()? {
self.remove_bundle()?;
}
Ok(success_message("Instance deleted"))
}
/// Running means a virtualOS process was started for the bundle and its guest still holds the
/// bundle's files; after a shutdown from inside the guest the virtualOS window stays open.
fn power_state(&mut self, name: &str, running: &BTreeMap<String, Vec<String>>) -> Result<&'static str> {
let bundle = self.bundle_path(Some(name))?;
Ok(if running.contains_key(&bundle) && !self.bundle_holders(&bundle)?.is_empty() { "poweron" } else { "poweroff" })
}
fn status(&mut self) -> Result<Value> {
self.require_vmname()?;
if !self.bundle_exists()? {
return Ok(json!({"status": "success", "power_state": "unknown"}));
}
let running = self.running_vms()?;
let name = self.config.vmname.clone();
Ok(json!({"status": "success", "power_state": self.power_state(&name, &running)?}))
}
fn statuses(&mut self) -> Result<Value> {
let running = self.running_vms()?;
let mut power_state = Map::new();
for name in self.list_bundles()? {
let state = self.power_state(&name, &running)?;
power_state.insert(name, json!(state));
}
Ok(json!({"status": "success", "power_state": power_state}))
}
fn get_ip_addresses(&mut self) -> Result<Value> {
self.require_vmname()?;
if !self.bundle_exists()? {
return Err(self.bundle_not_found()?);
}
let bundle = self.bundle_path(None)?;
let mac = normalize_mac(&value_text(self.read_parameters(&bundle)?.get("macAddress")));
let leases = self.sh("[ -r \"$1\" ] && cat \"$1\"; exit 0", &[DHCP_LEASES])?;
Ok(json!({"status": "success", "printmessage": "true", "message": leased_addresses(&leases, &mac)}))
}
}
fn execute(operation: &str, json_file_path: &str) -> Result<Value> {
let content = fs::read_to_string(json_file_path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => format!("JSON file not found: {json_file_path}"),
_ => format!("Failed to read {json_file_path}: {e}"),
})?;
let json_data: Value = serde_json::from_str(&content).map_err(|_| "Invalid JSON in file".to_string())?;
let mut manager = VirtualOSManager { config: Config::parse(&json_data)? };
match operation {
"create" => manager.create(),
"start" => manager.start(),
"stop" => manager.stop(),
"reboot" => manager.reboot(),
"delete" => manager.delete(),
"status" => manager.status(),
"statuses" => manager.statuses(),
"getconsole" => Err("Operation not supported".into()),
"getipaddresses" => manager.get_ip_addresses(),
_ => Err("Invalid action".into()),
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let result = if args.len() < 3 {
Err("Usage: virtualos <operation> '<json-file-path>'".to_string())
} else {
execute(&args[1].to_lowercase(), &args[2])
};
match result {
Ok(data) => println!("{data}"),
Err(message) => {
println!("{}", json!({"status": "error", "error": message}));
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_macs() {
assert_eq!(normalize_mac("2:0:A:7b:0:5"), "02:00:0a:7b:00:05");
assert_eq!(normalize_mac("02:00:0a:7b:00:05"), "02:00:0a:7b:00:05");
}
#[test]
fn quotes_shell_arguments() {
assert_eq!(shell_quote("/Users/me/x.bundle"), "/Users/me/x.bundle");
assert_eq!(shell_quote(""), "''");
assert_eq!(shell_quote("a b"), "'a b'");
assert_eq!(shell_quote("it's"), "'it'\"'\"'s'");
}
#[test]
fn builds_machine_identifier_plist() {
let plist = machine_identifier(0x0123_4567_89ab_cdef);
assert_eq!(&plist[..8], b"bplist00");
assert_eq!(&plist[17..25], &0x0123_4567_89ab_cdefu64.to_be_bytes());
assert_eq!(plist.len(), 28 + 32);
}
#[test]
fn finds_leased_addresses() {
let leases = "{\n\tname=mac\n\tip_address=192.168.64.7\n\thw_address=1,2:0:a:7b:0:5\n\tlease=0x6a0\n}\n\
{\n\tname=other\n\tip_address=192.168.64.8\n\thw_address=1,aa:bb:cc:dd:ee:ff\n}\n";
assert_eq!(leased_addresses(leases, "02:00:0a:7b:00:05"), vec!["192.168.64.7"]);
assert!(leased_addresses(leases, "02:00:0a:7b:00:06").is_empty());
}
#[test]
fn parses_details() {
let data = json!({
"externaldetails": {"extension": {"url": "localhost", "network_mode": "NAT"},
"host": {"ssh_port": 2222}, "virtualmachine": {"template_name": "tpl"}},
"cloudstack.vm.details": {"name": "i-2-10-VM", "cpus": 2, "minRam": 4294967296u64,
"nics": [{"deviceId": 1, "mac": "b"}, {"deviceId": 0, "mac": "a"}]}
});
let config = Config::parse(&data).unwrap();
assert!(config.local);
assert_eq!(config.ssh_port, "2222");
assert_eq!(config.network_mode, "nat");
assert_eq!(config.template_name, "tpl");
assert_eq!(config.macs, vec!["a", "b"]);
assert_eq!(config.memory, Some(4294967296));
assert!(Config::parse(&json!({"externaldetails": {"extension": {"url": "mac"}}})).is_err());
}
}