This is an automated email from the ASF dual-hosted git repository.

DaanHoogland pushed a commit to branch virtualos-extension
in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git


The following commit(s) were added to refs/heads/virtualos-extension by this 
push:
     new 5edab7c  virtualos: re-implement the extension in Rust
5edab7c is described below

commit 5edab7caa46bca2dfb01d77d3b2a1cf6ca2f60b8
Author: Daan Hoogland <[email protected]>
AuthorDate: Mon Oct 5 18:41:42 2026 +0200

    virtualos: re-implement the extension in Rust
---
 virtualos/.gitignore   |   1 +
 virtualos/Cargo.lock   | 128 ++++++++
 virtualos/Cargo.toml   |  29 ++
 virtualos/README.txt   |  25 +-
 virtualos/src/main.rs  | 782 +++++++++++++++++++++++++++++++++++++++++++++++++
 virtualos/virtualos.py | 479 ------------------------------
 6 files changed, 958 insertions(+), 486 deletions(-)

diff --git a/virtualos/.gitignore b/virtualos/.gitignore
new file mode 100644
index 0000000..b83d222
--- /dev/null
+++ b/virtualos/.gitignore
@@ -0,0 +1 @@
+/target/
diff --git a/virtualos/Cargo.lock b/virtualos/Cargo.lock
new file mode 100644
index 0000000..5532d51
--- /dev/null
+++ b/virtualos/Cargo.lock
@@ -0,0 +1,128 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "virtualos"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/virtualos/Cargo.toml b/virtualos/Cargo.toml
new file mode 100644
index 0000000..24d81e4
--- /dev/null
+++ b/virtualos/Cargo.toml
@@ -0,0 +1,29 @@
+# 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.
+
+[package]
+name = "virtualos"
+version = "0.1.0"
+edition = "2021"
+license = "Apache-2.0"
+description = "Apache CloudStack orchestrator extension for virtualOS on macOS"
+
+[dependencies]
+serde_json = { version = "1", features = ["preserve_order"] }
+
+[profile.release]
+strip = true
diff --git a/virtualos/README.txt b/virtualos/README.txt
index 78a0cf0..0d1bb4e 100644
--- a/virtualos/README.txt
+++ b/virtualos/README.txt
@@ -31,8 +31,8 @@ default passed as launch argument:
   open -n -g -a virtualOS --args -autostartVMBundlePath <dir>/<name>.bundle
 
 Each running instance is one virtualOS process; stopping an instance
-terminates that process. The script runs on the management server and
-reaches the Mac through SSH. When the management server itself runs on the
+terminates that process. The extension is a Rust program that runs on the
+management server and reaches the Mac through SSH. When the management server 
itself runs on the
 Mac (e.g. a development setup), the host url can be "localhost" and no SSH
 is used.
 
@@ -40,9 +40,13 @@ Requirements
 ------------
 
 Management server:
-  - python3 (standard library only)
+  - The virtualos binary built for the management server's platform (see
+    Building); it has no runtime dependencies besides the C library
   - ssh client; sshpass only when password authentication is used
 
+Building:
+  - A current stable Rust toolchain with cargo (https://rustup.rs)
+
 Mac:
   - Apple silicon, virtualOS 3.0 or later (the autostart setting is used)
   - The SSH user logged in to the GUI session, virtualOS is a GUI app
@@ -103,19 +107,26 @@ Setup
 1. In virtualOS, install a macOS VM to use as template, e.g. "macOS-15".
    Set it up as wanted (user account, Remote Login, ...) and shut it down.
 
-2. Copy virtualos.py to every management server:
+2. Build the binary on (or for) the management server's platform, e.g. on
+   a Linux management server:
+
+     cargo build --release
+
+   and copy it to every management server:
 
      mkdir -p /usr/share/cloudstack-management/extensions/virtualOS
-     cp virtualos.py 
/usr/share/cloudstack-management/extensions/virtualOS/virtualos.py
-     chmod 755 
/usr/share/cloudstack-management/extensions/virtualOS/virtualos.py
+     cp target/release/virtualos 
/usr/share/cloudstack-management/extensions/virtualOS/virtualos
+     chmod 755 /usr/share/cloudstack-management/extensions/virtualOS/virtualos
      chown -R cloud:cloud /usr/share/cloudstack-management/extensions/virtualOS
 
+   The unit tests run with "cargo test".
+
    For SSH key authentication, create a key for the cloud user and add the
    public key to ~/.ssh/authorized_keys of the user on the Mac.
 
 3. Register the extension and its custom action (CloudMonkey):
 
-     cmk create extension name=virtualOS type=Orchestrator path=virtualos.py
+     cmk create extension name=virtualOS type=Orchestrator path=virtualos
      cmk add customaction extensionid=<id> name=GetIpAddresses 
resourcetype=VirtualMachine
 
 4. Create a cluster with hypervisor External, register the extension to it,
diff --git a/virtualos/src/main.rs b/virtualos/src/main.rs
new file mode 100644
index 0000000..9be120d
--- /dev/null
+++ b/virtualos/src/main.rs
@@ -0,0 +1,782 @@
+// 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)
+//!
+//! 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 START_CHECK_DELAY: Duration = Duration::from_secs(5);
+
+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,
+    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"),
+            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())
+    }
+
+    fn start_errors(&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", "2m", 
"--style", "compact", "--predicate", &predicate])?;
+        let mut errors: Vec<String> = Vec::new();
+        for line in output.lines().filter(|l| l.contains("] Error")) {
+            let error = line.rsplit_once("] ").map(|(_, e)| 
e.to_string()).unwrap_or_default();
+            if !errors.contains(&error) {
+                errors.push(error);
+            }
+        }
+        Ok(errors)
+    }
+
+    fn start_vm(&mut self) -> Result<()> {
+        if !self.vm_pids(None)?.is_empty() {
+            return Ok(());
+        }
+        let bundle = self.bundle_path(None)?;
+        let app = self.app_path()?;
+        self.run(&["open", "-n", "-g", "-a", &app, "--args", 
AUTOSTART_ARGUMENT, &bundle])?;
+        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));
+        }
+        thread::sleep(START_CHECK_DELAY);
+        let pids = self.vm_pids(None)?;
+        let Some(pid) = pids.first() else {
+            return Err(format!("virtualOS exited while starting {vmname}"));
+        };
+        let errors = self.start_errors(pid).unwrap_or_default();
+        if !errors.is_empty() {
+            self.stop_vm(None)?;
+            return Err(format!("virtualOS failed to start {vmname}: {}", 
errors.join("; ")));
+        }
+        Ok(())
+    }
+
+    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)));
+        }
+        Ok(())
+    }
+
+    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.start_vm()?;
+        Ok(success_message("Instance started"))
+    }
+
+    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.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"))
+    }
+
+    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) { "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());
+    }
+}
diff --git a/virtualos/virtualos.py b/virtualos/virtualos.py
deleted file mode 100755
index 2154dea..0000000
--- a/virtualos/virtualos.py
+++ /dev/null
@@ -1,479 +0,0 @@
-#!/usr/bin/env python3
-# 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 script 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)
-
-Instance/template details:
-  template_name         VM bundle to clone, overrides the host/extension value
-"""
-
-import base64
-import json
-import math
-import os
-import plistlib
-import re
-import secrets
-import shlex
-import subprocess
-import sys
-import time
-
-DEFAULT_APP_PATH = "/Applications/virtualOS.app"
-VIRTUALOS_BUNDLE_ID = "com.github.yep.ios.virtualOS"
-VIRTUALOS_LOG_SUBSYSTEM = "com.github.virtualOS"
-DEFAULT_VM_DIRECTORY = 
f"~/Library/Containers/{VIRTUALOS_BUNDLE_ID}/Data/Documents"
-AUTOSTART_ARGUMENT = "-autostartVMBundlePath"
-NETWORK_MODES = {"nat": "NAT", "bridged": "Bridge"}
-LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1")
-DHCP_LEASES = "/var/db/dhcpd_leases"
-REQUIRED_BUNDLE_FILES = ("HardwareModel", "AuxiliaryStorage", "Parameters.txt")
-START_CHECK_DELAY = 5
-
-
-def fail(message):
-    print(json.dumps({"status": "error", "error": message}))
-    sys.exit(1)
-
-
-def succeed(data):
-    print(json.dumps(data))
-    sys.exit(0)
-
-
-def normalize_mac(mac):
-    return ":".join(f"{int(octet, 16):02x}" for octet in mac.split(":"))
-
-
-class VirtualOSError(Exception):
-    pass
-
-
-class VirtualOSManager:
-    def __init__(self, config_path):
-        self.data = self.parse_json(config_path)
-
-    def parse_json(self, config_path):
-        with open(config_path, 'r') as f:
-            json_data = json.load(f)
-
-        external = json_data.get("externaldetails", {})
-        extension = external.get("extension", {}) or {}
-        host = external.get("host", {}) or {}
-        vm = external.get("virtualmachine", {}) or {}
-
-        def detail(name, default=""):
-            return host.get(name) or extension.get(name) or default
-
-        def flag(name, default="false"):
-            return str(detail(name, default)).lower() == "true"
-
-        data = {
-            "url": detail("url"),
-            "username": detail("username"),
-            "password": detail("password"),
-            "ssh_key": detail("ssh_key"),
-            "ssh_port": str(detail("ssh_port", "22")),
-            "verify_host_key": flag("verify_host_key", "true"),
-            "app_path": detail("app_path"),
-            "vm_directory": detail("vm_directory", 
DEFAULT_VM_DIRECTORY).rstrip("/"),
-            "network_mode": detail("network_mode").lower(),
-            "bridge_interface": detail("bridge_interface"),
-            "wait_timeout": int(detail("wait_timeout", "120")),
-            "template_name": vm.get("template_name") or 
detail("template_name"),
-        }
-        if not data["url"]:
-            fail("Missing required field in JSON: url")
-        if data["network_mode"] and data["network_mode"] not in NETWORK_MODES:
-            fail(f"Invalid network_mode '{data['network_mode']}', expected one 
of {', '.join(NETWORK_MODES)}")
-        data["local"] = data["url"].lower() in LOCAL_HOSTS and not 
data["username"]
-        if not data["local"] and not data["username"]:
-            fail("Missing required field in JSON: username")
-
-        vm_details = json_data.get("cloudstack.vm.details", {}) or {}
-        data["vmname"] = vm_details.get("name", "")
-        data["cpus"] = vm_details.get("cpus")
-        data["memory"] = vm_details.get("minRam")
-        nics = sorted(vm_details.get("nics", []) or [], key=lambda n: 
n.get("deviceId", 0))
-        data["macs"] = [nic["mac"] for nic in nics if nic.get("mac")]
-        return data
-
-    def ssh_command(self, remote_argv):
-        cmd = [
-            "ssh",
-            "-o", "ConnectTimeout=15",
-            "-o", "StrictHostKeyChecking=" + ("yes" if 
self.data["verify_host_key"] else "no"),
-            "-p", self.data["ssh_port"],
-        ]
-        if not self.data["verify_host_key"]:
-            cmd += ["-o", "UserKnownHostsFile=/dev/null", "-o", 
"LogLevel=ERROR"]
-        if self.data["ssh_key"]:
-            cmd += ["-i", self.data["ssh_key"]]
-        if self.data["password"]:
-            cmd = ["sshpass", "-e"] + cmd + ["-o", "BatchMode=no"]
-        else:
-            cmd += ["-o", "BatchMode=yes"]
-        cmd += [f"{self.data['username']}@{self.data['url']}", "--",
-                " ".join(shlex.quote(a) for a in remote_argv)]
-        return cmd
-
-    def run(self, argv, stdin=None):
-        cmd = argv if self.data["local"] else self.ssh_command(argv)
-        env = None
-        if not self.data["local"] and self.data["password"]:
-            env = dict(os.environ, SSHPASS=self.data["password"])
-        try:
-            r = subprocess.run(cmd, capture_output=True, text=True, 
input=stdin,
-                               timeout=self.data["wait_timeout"] + 30, env=env)
-        except FileNotFoundError as e:
-            raise VirtualOSError(f"Command not found: {e.filename}")
-        except subprocess.TimeoutExpired:
-            raise VirtualOSError(f"Timed out running: {' '.join(argv)}")
-        if r.returncode != 0:
-            raise VirtualOSError((r.stderr or r.stdout).strip() or f"'{' 
'.join(argv)}' exited with {r.returncode}")
-        return r.stdout
-
-    def sh(self, script, *args, stdin=None):
-        return self.run(["sh", "-c", script, "sh"] + list(args), stdin=stdin)
-
-    def vm_directory(self):
-        directory = self.data["vm_directory"]
-        if directory == "~" or directory.startswith("~/"):
-            home = self.sh('printf %s "$HOME"').strip()
-            directory = home + directory[1:]
-            self.data["vm_directory"] = directory
-        return directory
-
-    def bundle_path(self, name=None):
-        return f"{self.vm_directory()}/{name or self.data['vmname']}.bundle"
-
-    def resolve_app(self):
-        script = ('[ -d "$1" ] && echo "$1" && exit 0; '
-                  f'mdfind "kMDItemCFBundleIdentifier == 
\'{VIRTUALOS_BUNDLE_ID}\'" | head -n 1')
-        try:
-            path = self.sh(script, DEFAULT_APP_PATH).strip()
-        except VirtualOSError:
-            path = ""
-        if not path:
-            raise VirtualOSError("virtualOS not found on the host, set the 
app_path detail")
-        return path
-
-    def app_path(self):
-        if not self.data["app_path"]:
-            self.data["app_path"] = self.resolve_app()
-        return self.data["app_path"]
-
-    def list_bundles(self):
-        script = 'cd "$1" 2>/dev/null || exit 0; for b in *.bundle; do [ -d 
"$b" ] && echo "${b%.bundle}"; done; exit 0'
-        return [name for name in self.sh(script, 
self.vm_directory()).splitlines() if name]
-
-    def bundle_exists(self, name=None):
-        return (name or self.data["vmname"]) in self.list_bundles()
-
-    def running_vms(self):
-        """Map bundle path to the pids of the virtualOS processes autostarting 
it."""
-        executable = "/Contents/MacOS/virtualOS "
-        marker = f" {AUTOSTART_ARGUMENT} "
-        running = {}
-        for line in self.run(["ps", "-axww", "-o", 
"pid=,command="]).splitlines():
-            pid, _, command = line.strip().partition(" ")
-            if executable in command and marker in command:
-                running.setdefault(command.split(marker, 1)[1].strip(), 
[]).append(pid)
-        return running
-
-    def vm_pids(self, name=None):
-        return self.running_vms().get(self.bundle_path(name), [])
-
-    def require_vmname(self):
-        if not self.data["vmname"]:
-            fail("Missing required field in JSON: cloudstack.vm.details.name")
-        if "/" in self.data["vmname"] or self.data["vmname"].startswith("."):
-            fail(f"Invalid instance name '{self.data['vmname']}'")
-
-    def read_parameters(self, bundle):
-        output = self.run(["cat", f"{bundle}/Parameters.txt"])
-        try:
-            return json.loads(output)
-        except json.JSONDecodeError:
-            raise VirtualOSError(f"Failed to parse {bundle}/Parameters.txt")
-
-    def write_file(self, path, content):
-        self.sh('cat > "$1"', path, stdin=content)
-
-    def write_machine_identifier(self, bundle):
-        # VZMacMachineIdentifier.dataRepresentation is a binary plist holding 
a random ECID
-        identifier = plistlib.dumps({"ECID": secrets.randbits(63)}, 
fmt=plistlib.FMT_BINARY)
-        self.sh('base64 -D > "$1"', f"{bundle}/MachineIdentifier",
-                stdin=base64.b64encode(identifier).decode())
-
-    def bridge_description(self):
-        # virtualOS matches the bridge by its display name, e.g. "Wi-Fi (en0)"
-        interface = self.data["bridge_interface"]
-        if not interface or "(" in interface:
-            return interface
-        port = None
-        for line in self.run(["networksetup", 
"-listallhardwareports"]).splitlines():
-            if line.startswith("Hardware Port: "):
-                port = line[len("Hardware Port: "):].strip()
-            elif line.startswith("Device: ") and line[len("Device: 
"):].strip() == interface and port:
-                return f"{port} ({interface})"
-        return interface
-
-    def configure(self, bundle):
-        parameters = self.read_parameters(bundle)
-        cpus = int(self.data["cpus"])
-        memory_gb = max(1, math.ceil(int(self.data["memory"]) / (1024 ** 3)))
-        parameters["cpuCount"] = cpus
-        parameters["cpuCountMax"] = max(cpus, 
int(parameters.get("cpuCountMax", cpus)))
-        parameters["cpuCountMin"] = min(cpus, 
int(parameters.get("cpuCountMin", cpus)))
-        parameters["memorySizeInGB"] = memory_gb
-        parameters["memorySizeInGBMax"] = max(memory_gb, 
int(parameters.get("memorySizeInGBMax", memory_gb)))
-        parameters["memorySizeInGBMin"] = min(memory_gb, 
int(parameters.get("memorySizeInGBMin", memory_gb)))
-        parameters["installFinished"] = True
-        if self.data["macs"]:
-            parameters["macAddress"] = normalize_mac(self.data["macs"][0])
-        mode = self.data["network_mode"]
-        if mode:
-            parameters["networkType"] = NETWORK_MODES[mode]
-            if mode == "bridged":
-                parameters["networkBridge"] = self.bridge_description()
-        self.write_file(f"{bundle}/Parameters.txt", json.dumps(parameters, 
indent=2))
-
-    def start_errors(self, pid):
-        output = self.run(["/usr/bin/log", "show", "--last", "2m", "--style", 
"compact", "--predicate",
-                           f'subsystem == "{VIRTUALOS_LOG_SUBSYSTEM}" AND 
processID == {pid}'])
-        errors = [line.rsplit("] ", 1)[1] for line in output.splitlines() if 
"] Error" in line]
-        return list(dict.fromkeys(errors))
-
-    def start_vm(self):
-        if self.vm_pids():
-            return
-        bundle = self.bundle_path()
-        self.run(["open", "-n", "-g", "-a", self.app_path(), "--args", 
AUTOSTART_ARGUMENT, bundle])
-        deadline = time.time() + self.data["wait_timeout"]
-        while not self.vm_pids():
-            if time.time() > deadline:
-                raise VirtualOSError(f"Timed out waiting for virtualOS to 
start {self.data['vmname']}")
-            time.sleep(1)
-        time.sleep(START_CHECK_DELAY)
-        pids = self.vm_pids()
-        if not pids:
-            raise VirtualOSError(f"virtualOS exited while starting 
{self.data['vmname']}")
-        try:
-            errors = self.start_errors(pids[0])
-        except VirtualOSError:
-            errors = []
-        if errors:
-            self.stop_vm()
-            raise VirtualOSError(f"virtualOS failed to start 
{self.data['vmname']}: {'; '.join(errors)}")
-
-    def stop_vm(self, name=None):
-        pids = self.vm_pids(name)
-        if not pids:
-            return
-        self.run(["kill", "-TERM"] + pids)
-        deadline = time.time() + self.data["wait_timeout"]
-        while self.vm_pids(name):
-            if time.time() > deadline:
-                self.run(["kill", "-KILL"] + self.vm_pids(name))
-                time.sleep(2)
-                break
-            time.sleep(1)
-        if self.vm_pids(name):
-            raise VirtualOSError(f"Failed to stop {name or 
self.data['vmname']}")
-
-    def remove_bundle(self, name=None):
-        bundle = self.bundle_path(name)
-        if not bundle.endswith(".bundle") or not (name or self.data["vmname"]):
-            raise VirtualOSError(f"Refusing to remove '{bundle}'")
-        self.run(["rm", "-rf", bundle])
-
-    def create(self):
-        self.require_vmname()
-        vm_name = self.data["vmname"]
-        template = self.data["template_name"]
-        if not template:
-            fail("Missing required field in JSON: template_name")
-        if self.data["cpus"] is None or self.data["memory"] is None:
-            fail("Missing CPU or memory in cloudstack.vm.details")
-        if len(self.data["macs"]) > 1:
-            fail("virtualOS supports a single network interface per VM")
-
-        bundles = self.list_bundles()
-        if template not in bundles:
-            fail(f"Template VM bundle '{template}' not found in 
{self.vm_directory()}")
-        if vm_name in bundles:
-            fail(f"A virtualOS VM named '{vm_name}' already exists")
-        template_bundle = self.bundle_path(template)
-        missing = self.sh('cd "$1" && shift && for f; do [ -e "$f" ] || echo 
"$f"; done; exit 0',
-                          template_bundle, *REQUIRED_BUNDLE_FILES).split()
-        if missing:
-            fail(f"Template VM bundle '{template}' is incomplete, missing: {', 
'.join(missing)}")
-        if self.vm_pids(template):
-            fail(f"Template VM '{template}' is running, stop it before 
cloning")
-
-        bundle = self.bundle_path()
-        cloned = False
-        try:
-            # -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)
-            cloned = True
-            self.write_machine_identifier(bundle)
-            self.configure(bundle)
-            self.start_vm()
-            succeed({"status": "success", "message": "Instance created"})
-        except VirtualOSError as e:
-            if cloned:
-                try:
-                    self.stop_vm()
-                    self.remove_bundle()
-                except VirtualOSError:
-                    pass
-            fail(str(e))
-
-    def start(self):
-        self.require_vmname()
-        if not self.bundle_exists():
-            fail(f"VM bundle '{self.data['vmname']}' not found in 
{self.vm_directory()}")
-        self.start_vm()
-        succeed({"status": "success", "message": "Instance started"})
-
-    def stop(self):
-        self.require_vmname()
-        self.stop_vm()
-        succeed({"status": "success", "message": "Instance stopped"})
-
-    def reboot(self):
-        self.require_vmname()
-        self.stop_vm()
-        self.start_vm()
-        succeed({"status": "success", "message": "Instance rebooted"})
-
-    def delete(self):
-        self.require_vmname()
-        self.stop_vm()
-        if self.bundle_exists():
-            self.remove_bundle()
-        succeed({"status": "success", "message": "Instance deleted"})
-
-    def power_state(self, name, running):
-        if self.bundle_path(name) in running:
-            return "poweron"
-        return "poweroff"
-
-    def status(self):
-        self.require_vmname()
-        if not self.bundle_exists():
-            succeed({"status": "success", "power_state": "unknown"})
-        succeed({"status": "success", "power_state": 
self.power_state(self.data["vmname"], self.running_vms())})
-
-    def statuses(self):
-        running = self.running_vms()
-        power_state = {name: self.power_state(name, running) for name in 
self.list_bundles()}
-        succeed({"status": "success", "power_state": power_state})
-
-    def get_console(self):
-        fail("Operation not supported")
-
-    def get_ip_addresses(self):
-        self.require_vmname()
-        if not self.bundle_exists():
-            fail(f"VM bundle '{self.data['vmname']}' not found in 
{self.vm_directory()}")
-        mac = 
normalize_mac(self.read_parameters(self.bundle_path()).get("macAddress", "0"))
-        leases = self.sh('[ -r "$1" ] && cat "$1"; exit 0', DHCP_LEASES)
-        addresses = []
-        for lease in re.findall(r"\{(.*?)\}", leases, re.S):
-            fields = dict(line.strip().split("=", 1) for line in 
lease.splitlines() if "=" in line)
-            hw_address = fields.get("hw_address", "").split(",", 1)[-1]
-            if hw_address and normalize_mac(hw_address) == mac and 
fields.get("ip_address"):
-                addresses.append(fields["ip_address"])
-        succeed({"status": "success", "printmessage": "true", "message": 
addresses})
-
-
-def main():
-    if len(sys.argv) < 3:
-        fail("Usage: virtualos.py <operation> '<json-file-path>'")
-
-    operation = sys.argv[1].lower()
-    json_file_path = sys.argv[2]
-
-    try:
-        manager = VirtualOSManager(json_file_path)
-    except FileNotFoundError:
-        fail(f"JSON file not found: {json_file_path}")
-    except json.JSONDecodeError:
-        fail("Invalid JSON in file")
-    except (KeyError, ValueError) as e:
-        fail(f"Error parsing JSON: {str(e)}")
-
-    operations = {
-        "create": manager.create,
-        "start": manager.start,
-        "stop": manager.stop,
-        "reboot": manager.reboot,
-        "delete": manager.delete,
-        "status": manager.status,
-        "statuses": manager.statuses,
-        "getconsole": manager.get_console,
-        "getipaddresses": manager.get_ip_addresses,
-    }
-
-    if operation not in operations:
-        fail("Invalid action")
-
-    try:
-        operations[operation]()
-    except VirtualOSError as e:
-        fail(str(e))
-
-
-if __name__ == "__main__":
-    main()

Reply via email to