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

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


The following commit(s) were added to refs/heads/parallels-rust-extension by 
this push:
     new 888ec3f  parallels rust implementation (untested)
888ec3f is described below

commit 888ec3f58e7fa7e826afa2b4bd10898fe48fbca9
Author: Daan Hoogland <[email protected]>
AuthorDate: Tue Oct 20 15:02:49 2026 +0200

    parallels rust implementation (untested)
---
 parallels-rs/.gitignore   |   1 +
 parallels-rs/Cargo.lock   | 105 ++++++
 parallels-rs/Cargo.toml   |  32 ++
 parallels-rs/README.txt   | 166 ++++++++++
 parallels-rs/rustfmt.toml |  18 ++
 parallels-rs/src/main.rs  | 802 ++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 1124 insertions(+)

diff --git a/parallels-rs/.gitignore b/parallels-rs/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/parallels-rs/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/parallels-rs/Cargo.lock b/parallels-rs/Cargo.lock
new file mode 100644
index 0000000..6fb9d01
--- /dev/null
+++ b/parallels-rs/Cargo.lock
@@ -0,0 +1,105 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[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 = "parallels"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[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 = [
+ "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 = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/parallels-rs/Cargo.toml b/parallels-rs/Cargo.toml
new file mode 100644
index 0000000..e7e52ec
--- /dev/null
+++ b/parallels-rs/Cargo.toml
@@ -0,0 +1,32 @@
+# 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 = "parallels"
+version = "0.1.0"
+edition = "2021"
+rust-version = "1.71"
+description = "CloudStack orchestrator extension for Parallels Desktop on 
macOS"
+license = "Apache-2.0"
+publish = false
+
+[dependencies]
+serde_json = "1"
+
+[profile.release]
+strip = true
+lto = true
diff --git a/parallels-rs/README.txt b/parallels-rs/README.txt
new file mode 100644
index 0000000..957e5b2
--- /dev/null
+++ b/parallels-rs/README.txt
@@ -0,0 +1,166 @@
+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.
+
+Parallels Desktop Orchestrator Extension (Rust)
+===============================================
+
+Rust implementation of the Parallels Desktop orchestrator extension. It lets
+Apache CloudStack manage virtual machines in Parallels Desktop
+(https://www.parallels.com/products/desktop/) on macOS, and behaves the same
+as the Python implementation in ../parallels: same operations, custom
+actions, configuration details and JSON output. It compiles to a single
+self-contained executable, so the management server needs no Python.
+
+Each macOS machine running Parallels Desktop is added to CloudStack as a
+host in a cluster mapped to this extension. The executable runs on the
+management server and drives Parallels Desktop on the Mac through SSH,
+using its command line tool `prlctl`. 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.
+
+Requirements
+------------
+
+Build:
+  - Rust 1.71 or later (cargo)
+
+Management server:
+  - ssh client; sshpass only when password authentication is used
+
+Mac:
+  - Parallels Desktop Pro or Business Edition; prlctl refuses most commands
+    on the Standard Edition ("The command is available only in Parallels
+    Desktop for Mac Pro or Business Edition")
+  - Remote Login (SSH) enabled for the user owning the Parallels VMs
+  - One or more Parallels virtual machines or templates to clone from;
+    Parallels Tools installed in them for graceful stop and IP reporting
+
+Supported operations
+--------------------
+
+  create          Clone the template VM, name it after the CloudStack
+                  instance's internal name, set CPU count, memory and NIC
+                  MAC addresses (adding or removing NICs to match the
+                  instance), then start it. The clone is removed again when
+                  any step fails.
+  start, stop, reboot, delete, status, statuses
+                  stop shuts down gracefully and kills the VM when that
+                  does not finish within wait_timeout
+  getconsole      Not supported, prlctl has no VNC endpoint to hand out.
+
+Custom actions (register them with addCustomAction):
+  Suspend         Pause the VM in memory
+  Resume          Resume a paused VM
+  GetIpAddresses  List IP addresses reported by Parallels Tools
+  ListSnapshots   List the VM's snapshots
+  CreateSnapshot  Create a snapshot, parameter snapshot_name
+  RestoreSnapshot Revert to a snapshot, parameter snapshot_name
+  DeleteSnapshot  Delete a snapshot, parameter snapshot_name
+
+Configuration details
+---------------------
+
+Extension or host details (host details win):
+  url               Mac hostname/IP, or "localhost" to run locally
+  username          SSH user owning the Parallels VMs; leave empty for local
+  password          Optional SSH password (needs sshpass); keys preferred
+  ssh_key           Optional private key path on the management server
+  ssh_port          Optional, default 22
+  verify_host_key   Optional, "true" (default) or "false"
+  prlctl_path       Optional; by default /usr/local/bin/prlctl and the one in
+                    /Applications/Parallels Desktop.app are tried, then the
+                    Parallels Desktop bundle is looked up with Spotlight
+  template_name     Optional default Parallels VM or template to clone
+  linked_clone      Optional, "true" to create linked clones, default "false"
+  network_mode      Optional: shared, bridged or host-only. When set it is
+                    applied to every NIC, otherwise template NICs keep their
+                    mode and additional NICs use shared
+  bridge_interface  macOS interface for bridged mode, e.g. en0
+  headless          Optional, "true" to start instances without a window
+  wait_timeout      Optional, seconds to wait for state changes, default 120
+
+Template, service offering or instance details:
+  template_name     Parallels VM to clone, overrides the host/extension value
+
+Building
+--------
+
+The executable runs on the management server, so build it on (or for) the
+management server's platform, not for the Mac:
+
+  cargo build --release
+  cargo test
+
+The result is target/release/parallels. To build for a Linux management
+server from another machine, add the matching target, for example:
+
+  rustup target add x86_64-unknown-linux-musl
+  cargo build --release --target x86_64-unknown-linux-musl
+
+Setup
+-----
+
+1. Copy the executable to every management server:
+
+     mkdir -p /usr/share/cloudstack-management/extensions/Parallels
+     cp target/release/parallels 
/usr/share/cloudstack-management/extensions/Parallels/parallels
+     chmod 755 /usr/share/cloudstack-management/extensions/Parallels/parallels
+     chown -R cloud:cloud /usr/share/cloudstack-management/extensions/Parallels
+
+   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.
+
+2. Register the extension and its custom actions (CloudMonkey):
+
+     cmk create extension name=Parallels type=Orchestrator path=parallels
+     cmk add customaction extensionid=<id> name=Suspend 
resourcetype=VirtualMachine
+     cmk add customaction extensionid=<id> name=Resume 
resourcetype=VirtualMachine
+     cmk add customaction extensionid=<id> name=GetIpAddresses 
resourcetype=VirtualMachine
+     cmk add customaction extensionid=<id> name=ListSnapshots 
resourcetype=VirtualMachine
+     for action in CreateSnapshot RestoreSnapshot DeleteSnapshot; do
+       cmk add customaction extensionid=<id> name=$action 
resourcetype=VirtualMachine \
+         parameters[0].name=snapshot_name parameters[0].type=STRING \
+         parameters[0].validationformat=NONE parameters[0].required=true
+     done
+
+3. Create a cluster with hypervisor External, register the extension to it,
+   and add each Mac as a host with url, username and (optionally) ssh_key,
+   network_mode and bridge_interface as details.
+
+4. Register a template with hypervisor External and extension Parallels,
+   using a dummy url, and set the external detail template_name to the name
+   of the Parallels VM to clone.
+
+5. Deploy instances from that template.
+
+Limitations
+-----------
+
+  - No VLAN isolation: Parallels Desktop cannot tag VLANs. Instances get the
+    CloudStack MAC addresses and the configured network mode only, so the
+    guest network must be reachable through the Mac's shared or bridged
+    networking.
+  - No console access.
+  - The Parallels VM name equals the CloudStack internal instance name;
+    renaming the VM in Parallels Desktop breaks the mapping.
+  - Linked clones need a snapshot on the template VM.
+  - Tested against prlctl 26.4 without a Pro license and without VMs: unit
+    tests cover payload parsing, quoting, state mapping and SSH command
+    construction, and the output matches the Python implementation for
+    status, statuses, delete of a missing VM and the error paths. create,
+    NIC reconfiguration and the snapshot actions still need verification
+    on a Pro/Business installation.
diff --git a/parallels-rs/rustfmt.toml b/parallels-rs/rustfmt.toml
new file mode 100644
index 0000000..c4d8ac9
--- /dev/null
+++ b/parallels-rs/rustfmt.toml
@@ -0,0 +1,18 @@
+# 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.
+
+max_width = 120
diff --git a/parallels-rs/src/main.rs b/parallels-rs/src/main.rs
new file mode 100644
index 0000000..01f456c
--- /dev/null
+++ b/parallels-rs/src/main.rs
@@ -0,0 +1,802 @@
+// 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 Parallels Desktop on macOS.
+//!
+//! The management server reaches the Mac over SSH (or runs locally when the
+//! management server itself runs on the Mac) and drives Parallels Desktop
+//! through its command line tool `prlctl`, which requires the Pro or Business
+//! edition of Parallels Desktop.
+//!
+//! Instances are created by cloning an existing Parallels virtual machine or
+//! template, after which CPU count, memory and NIC MAC addresses are set from
+//! the CloudStack instance. The Parallels virtual machine is named after the
+//! CloudStack instance's internal name.
+//!
+//! See README.txt for the supported details and custom actions.
+
+use serde_json::{json, Map, Value};
+use std::collections::BTreeMap;
+use std::io::Read;
+use std::process::{Command, Stdio};
+use std::thread;
+use std::time::{Duration, Instant};
+
+const DEFAULT_PRLCTL_PATHS: [&str; 2] = [
+    "/usr/local/bin/prlctl",
+    "/Applications/Parallels Desktop.app/Contents/MacOS/prlctl",
+];
+const PARALLELS_BUNDLE_ID: &str = "com.parallels.desktop.console";
+const NETWORK_MODES: [&str; 3] = ["shared", "bridged", "host-only"];
+const LOCAL_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
+
+const POWER_ON_STATES: [&str; 7] = [
+    "running",
+    "starting",
+    "paused",
+    "pausing",
+    "continuing",
+    "resuming",
+    "resetting",
+];
+const POWER_OFF_STATES: [&str; 2] = ["stopped", "suspended"];
+
+type Result<T> = std::result::Result<T, String>;
+
+#[derive(Debug)]
+struct Config {
+    url: String,
+    username: String,
+    password: String,
+    ssh_key: String,
+    ssh_port: String,
+    verify_host_key: bool,
+    prlctl: String,
+    linked_clone: bool,
+    network_mode: String,
+    bridge_interface: String,
+    headless: bool,
+    wait_timeout: u64,
+    template_name: String,
+    local: bool,
+    vmname: String,
+    cpus: Option<String>,
+    memory: Option<u64>,
+    macs: Vec<String>,
+    snapshot_name: String,
+}
+
+/// Renders a JSON scalar as a detail value; empty strings and null count as 
unset.
+fn value_str(value: Option<&Value>) -> Option<String> {
+    match value? {
+        Value::String(s) if !s.is_empty() => Some(s.clone()),
+        Value::Number(n) => Some(n.to_string()),
+        Value::Bool(b) => Some(b.to_string()),
+        _ => None,
+    }
+}
+
+fn object(value: Option<&Value>) -> Option<&Map<String, Value>> {
+    value.and_then(Value::as_object)
+}
+
+impl Config {
+    fn parse(json_data: &Value) -> Result<Config> {
+        let empty = Map::new();
+        let external = 
object(json_data.get("externaldetails")).unwrap_or(&empty);
+        let extension = object(external.get("extension")).unwrap_or(&empty);
+        let host = object(external.get("host")).unwrap_or(&empty);
+        let vm = object(external.get("virtualmachine")).unwrap_or(&empty);
+
+        let detail = |name: &str, default: &str| -> String {
+            value_str(host.get(name))
+                .or_else(|| value_str(extension.get(name)))
+                .unwrap_or_else(|| default.to_string())
+        };
+        let flag = |name: &str, default: &str| detail(name, 
default).to_lowercase() == "true";
+
+        let wait_timeout = detail("wait_timeout", "120");
+        let mut config = Config {
+            url: detail("url", ""),
+            username: detail("username", ""),
+            password: detail("password", ""),
+            ssh_key: detail("ssh_key", ""),
+            ssh_port: detail("ssh_port", "22"),
+            verify_host_key: flag("verify_host_key", "true"),
+            prlctl: detail("prlctl_path", ""),
+            linked_clone: flag("linked_clone", "false"),
+            network_mode: detail("network_mode", "").to_lowercase(),
+            bridge_interface: detail("bridge_interface", ""),
+            headless: flag("headless", "false"),
+            wait_timeout: wait_timeout
+                .parse()
+                .map_err(|_| format!("Error parsing JSON: invalid wait_timeout 
'{wait_timeout}'"))?,
+            template_name: 
value_str(vm.get("template_name")).unwrap_or_else(|| detail("template_name", 
"")),
+            local: false,
+            vmname: String::new(),
+            cpus: None,
+            memory: None,
+            macs: Vec::new(),
+            snapshot_name: String::new(),
+        };
+        if config.url.is_empty() {
+            return Err("Missing required field in JSON: url".into());
+        }
+        if !config.network_mode.is_empty() && 
!NETWORK_MODES.contains(&config.network_mode.as_str()) {
+            return Err(format!(
+                "Invalid network_mode '{}', expected one of {}",
+                config.network_mode,
+                NETWORK_MODES.join(", ")
+            ));
+        }
+        if config.network_mode == "bridged" && 
config.bridge_interface.is_empty() {
+            return Err("Missing required field in JSON: bridge_interface 
(required for bridged network_mode)".into());
+        }
+        config.local = 
LOCAL_HOSTS.contains(&config.url.to_lowercase().as_str()) && 
config.username.is_empty();
+        if !config.local && config.username.is_empty() {
+            return Err("Missing required field in JSON: username".into());
+        }
+
+        let vm_details = 
object(json_data.get("cloudstack.vm.details")).unwrap_or(&empty);
+        config.vmname = value_str(vm_details.get("name")).unwrap_or_default();
+        config.cpus = value_str(vm_details.get("cpus"));
+        config.memory = match value_str(vm_details.get("minRam")) {
+            Some(m) => Some(
+                m.parse()
+                    .map_err(|_| format!("Error parsing JSON: invalid minRam 
'{m}'"))?,
+            ),
+            None => None,
+        };
+        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(|n| 
n.get("deviceId").and_then(Value::as_i64).unwrap_or(0));
+        config.macs = nics.iter().filter_map(|n| 
value_str(n.get("mac"))).collect();
+
+        let parameters = object(json_data.get("parameters")).unwrap_or(&empty);
+        config.snapshot_name = value_str(parameters.get("snapshot_name"))
+            .unwrap_or_default()
+            .trim()
+            .to_string();
+        Ok(config)
+    }
+}
+
+/// Quotes an argument for a POSIX shell, like Python's shlex.quote.
+fn shell_quote(arg: &str) -> String {
+    if arg.is_empty() {
+        return "''".into();
+    }
+    if arg
+        .chars()
+        .all(|c| c.is_ascii_alphanumeric() || "@%+=:,./-_".contains(c))
+    {
+        return arg.into();
+    }
+    format!("'{}'", arg.replace('\'', "'\"'\"'"))
+}
+
+fn power_state(state: Option<&str>) -> &'static str {
+    match state {
+        Some(s) if POWER_ON_STATES.contains(&s) => "poweron",
+        Some(s) if POWER_OFF_STATES.contains(&s) => "poweroff",
+        _ => "unknown",
+    }
+}
+
+/// Network adapters (net0, net1, ...) of a `prlctl list --info --json` 
Hardware section, in order.
+fn net_devices(hardware: &Map<String, Value>) -> Vec<String> {
+    let mut devices: Vec<(u32, String)> = hardware
+        .keys()
+        .filter_map(|d| {
+            d.strip_prefix("net")
+                .and_then(|n| n.parse().ok())
+                .map(|n| (n, d.clone()))
+        })
+        .collect();
+    devices.sort();
+    devices.into_iter().map(|(_, d)| d).collect()
+}
+
+fn parse_ip_addresses(output: &str) -> Vec<String> {
+    output
+        .replace(',', " ")
+        .split_whitespace()
+        .filter(|a| *a != "-")
+        .map(String::from)
+        .collect()
+}
+
+fn success(message: &str) -> Value {
+    json!({"status": "success", "message": message})
+}
+
+struct ParallelsManager {
+    data: Config,
+}
+
+impl ParallelsManager {
+    fn ssh_command(&self, remote_argv: &[String]) -> Vec<String> {
+        let d = &self.data;
+        let mut cmd: Vec<String> = vec![
+            "ssh".into(),
+            "-o".into(),
+            "ConnectTimeout=15".into(),
+            "-o".into(),
+            format!("StrictHostKeyChecking={}", if d.verify_host_key { "yes" } 
else { "no" }),
+            "-p".into(),
+            d.ssh_port.clone(),
+        ];
+        if !d.verify_host_key {
+            cmd.extend(["-o", "UserKnownHostsFile=/dev/null", "-o", 
"LogLevel=ERROR"].map(String::from));
+        }
+        if !d.ssh_key.is_empty() {
+            cmd.extend(["-i".into(), d.ssh_key.clone()]);
+        }
+        if !d.password.is_empty() {
+            cmd.splice(0..0, ["sshpass", "-e"].map(String::from));
+            cmd.extend(["-o", "BatchMode=no"].map(String::from));
+        } else {
+            cmd.extend(["-o", "BatchMode=yes"].map(String::from));
+        }
+        let remote: Vec<String> = remote_argv.iter().map(|a| 
shell_quote(a)).collect();
+        cmd.extend([format!("{}@{}", d.username, d.url), "--".into(), 
remote.join(" ")]);
+        cmd
+    }
+
+    fn run(&self, argv: &[String]) -> Result<String> {
+        let cmd = if self.data.local {
+            argv.to_vec()
+        } else {
+            self.ssh_command(argv)
+        };
+        let mut command = Command::new(&cmd[0]);
+        command
+            .args(&cmd[1..])
+            .stdin(Stdio::null())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped());
+        if !self.data.local && !self.data.password.is_empty() {
+            command.env("SSHPASS", &self.data.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]),
+        })?;
+
+        let read_all = |mut pipe: Box<dyn Read + Send>| {
+            thread::spawn(move || {
+                let mut s = String::new();
+                let _ = pipe.read_to_string(&mut s);
+                s
+            })
+        };
+        let stdout = read_all(Box::new(child.stdout.take().expect("piped 
stdout")));
+        let stderr = read_all(Box::new(child.stderr.take().expect("piped 
stderr")));
+
+        let deadline = Instant::now() + 
Duration::from_secs(self.data.wait_timeout + 30);
+        let status = loop {
+            if let Some(status) = child.try_wait().map_err(|e| e.to_string())? 
{
+                break status;
+            }
+            if Instant::now() > deadline {
+                let _ = child.kill();
+                let _ = child.wait();
+                return Err(format!("Timed out running: {}", argv.join(" ")));
+            }
+            thread::sleep(Duration::from_millis(50));
+        };
+        let stdout = stdout.join().unwrap_or_default();
+        let stderr = stderr.join().unwrap_or_default();
+        if !status.success() {
+            let message = if stderr.trim().is_empty() {
+                stdout.trim()
+            } else {
+                stderr.trim()
+            };
+            return Err(if message.is_empty() {
+                format!("'{}' exited with {}", argv.join(" "), 
status.code().unwrap_or(-1))
+            } else {
+                message.to_string()
+            });
+        }
+        Ok(stdout)
+    }
+
+    fn resolve_prlctl(&self) -> Result<String> {
+        let candidates: Vec<String> = DEFAULT_PRLCTL_PATHS.iter().map(|p| 
shell_quote(p)).collect();
+        let script = format!(
+            "for p in {}; do [ -x \"$p\" ] && echo \"$p\" && exit 0; done; \
+             p=\"$(mdfind \"kMDItemCFBundleIdentifier == 
'{PARALLELS_BUNDLE_ID}'\" | head -n 1)/Contents/MacOS/prlctl\"; \
+             [ -x \"$p\" ] && echo \"$p\"",
+            candidates.join(" ")
+        );
+        let path = self
+            .run(&["sh".into(), "-c".into(), script])
+            .map(|p| p.trim().to_string())
+            .unwrap_or_default();
+        if path.is_empty() {
+            return Err("Parallels Desktop not found on the host, set the 
prlctl_path detail".into());
+        }
+        Ok(path)
+    }
+
+    fn prlctl(&mut self, args: &[&str]) -> Result<String> {
+        if self.data.prlctl.is_empty() {
+            self.data.prlctl = self.resolve_prlctl()?;
+        }
+        let mut argv = vec![self.data.prlctl.clone()];
+        argv.extend(args.iter().map(|a| a.to_string()));
+        self.run(&argv)
+    }
+
+    fn prlctl_json(&mut self, args: &[&str]) -> Result<Value> {
+        let output = self.prlctl(args)?;
+        let output = output.trim();
+        if output.is_empty() {
+            return Ok(Value::Null);
+        }
+        serde_json::from_str(output).map_err(|_| format!("Failed to parse 
prlctl output: {output}"))
+    }
+
+    fn list_vms(&mut self, templates: bool) -> Result<BTreeMap<String, 
String>> {
+        let mut args = vec!["list", "--all", "--json"];
+        if templates {
+            args.push("--template");
+        }
+        let vms = self.prlctl_json(&args)?;
+        Ok(vms
+            .as_array()
+            .map(|vms| {
+                vms.iter()
+                    .filter_map(|vm| {
+                        let name = value_str(vm.get("name"))?;
+                        Some((name, 
value_str(vm.get("status")).unwrap_or_default().to_lowercase()))
+                    })
+                    .collect()
+            })
+            .unwrap_or_default())
+    }
+
+    fn vm_state(&mut self, name: &str) -> Result<Option<String>> {
+        Ok(self.list_vms(false)?.remove(name))
+    }
+
+    fn vm_info(&mut self, name: &str) -> Result<Value> {
+        let info = self.prlctl_json(&["list", "--info", "--json", name])?;
+        Ok(match info {
+            Value::Array(mut items) if !items.is_empty() => 
items.swap_remove(0),
+            Value::Object(_) => info,
+            _ => json!({}),
+        })
+    }
+
+    fn wait_for_state(&mut self, states: &[&str], name: &str) -> 
Result<String> {
+        let deadline = Instant::now() + 
Duration::from_secs(self.data.wait_timeout);
+        loop {
+            let state = self.vm_state(name)?;
+            if let Some(s) = state.as_deref().filter(|s| states.contains(s)) {
+                return Ok(s.to_string());
+            }
+            if Instant::now() > deadline {
+                return Err(format!(
+                    "Timed out waiting for {name} to reach {}, current state: 
{}",
+                    states.join("/"),
+                    state.as_deref().unwrap_or("None")
+                ));
+            }
+            thread::sleep(Duration::from_secs(2));
+        }
+    }
+
+    fn require_vmname(&self) -> Result<String> {
+        if self.data.vmname.is_empty() {
+            return Err("Missing required field in JSON: 
cloudstack.vm.details.name".into());
+        }
+        Ok(self.data.vmname.clone())
+    }
+
+    fn require_snapshot_name(&self) -> Result<String> {
+        if self.data.snapshot_name.is_empty() {
+            return Err("Missing required field in JSON: snapshot_name".into());
+        }
+        Ok(self.data.snapshot_name.clone())
+    }
+
+    fn stop_vm(&mut self, name: &str) -> Result<()> {
+        match self.vm_state(name)?.as_deref() {
+            None | Some("stopped") => Ok(()),
+            Some("suspended") => self.prlctl(&["stop", name, 
"--drop-state"]).map(|_| ()),
+            Some(_) => {
+                let graceful = self
+                    .prlctl(&["stop", name])
+                    .and_then(|_| self.wait_for_state(&["stopped"], name));
+                if graceful.is_err() {
+                    self.prlctl(&["stop", name, "--kill"])?;
+                    self.wait_for_state(&["stopped"], name)?;
+                }
+                Ok(())
+            }
+        }
+    }
+
+    fn configure_nics(&mut self, vm_name: &str) -> Result<()> {
+        let info = self.vm_info(vm_name)?;
+        let existing = info
+            .get("Hardware")
+            .and_then(Value::as_object)
+            .map(net_devices)
+            .unwrap_or_default();
+        let mode = self.data.network_mode.clone();
+        let mut mode_args: Vec<String> = Vec::new();
+        if !mode.is_empty() {
+            mode_args.extend(["--type".into(), mode.clone()]);
+            if mode == "bridged" {
+                mode_args.extend(["--iface".into(), 
self.data.bridge_interface.clone()]);
+            }
+        }
+        let macs = self.data.macs.clone();
+        for (i, mac) in macs.iter().enumerate() {
+            let mac = mac.replace(':', "").to_uppercase();
+            let mut args: Vec<String> = vec!["set".into(), vm_name.into()];
+            match existing.get(i) {
+                Some(device) => {
+                    args.extend(["--device-set".into(), device.clone(), 
"--mac".into(), mac]);
+                    args.extend(mode_args.iter().cloned());
+                }
+                None => {
+                    args.extend(["--device-add".into(), "net".into(), 
"--mac".into(), mac]);
+                    if mode_args.is_empty() {
+                        args.extend(["--type".into(), "shared".into()]);
+                    } else {
+                        args.extend(mode_args.iter().cloned());
+                    }
+                }
+            }
+            let args: Vec<&str> = args.iter().map(String::as_str).collect();
+            self.prlctl(&args)?;
+        }
+        for device in existing.iter().skip(macs.len()) {
+            self.prlctl(&["set", vm_name, "--device-del", device])?;
+        }
+        Ok(())
+    }
+
+    fn create(&mut self) -> Result<Value> {
+        let vm_name = self.require_vmname()?;
+        let template = self.data.template_name.clone();
+        if template.is_empty() {
+            return Err("Missing required field in JSON: template_name".into());
+        }
+        let (Some(cpus), Some(memory)) = (self.data.cpus.clone(), 
self.data.memory) else {
+            return Err("Missing CPU or memory in 
cloudstack.vm.details".into());
+        };
+
+        let vms = self.list_vms(true)?;
+        if !vms.contains_key(&template) {
+            return Err(format!("Template VM '{template}' not found in 
Parallels Desktop"));
+        }
+        if vms.contains_key(&vm_name) {
+            return Err(format!("A Parallels VM named '{vm_name}' already 
exists"));
+        }
+
+        let mut clone_args = vec!["clone", template.as_str(), "--name", 
vm_name.as_str()];
+        if self.data.linked_clone {
+            clone_args.push("--linked");
+        }
+        self.prlctl(&clone_args)?;
+
+        let result = (|| {
+            let memory_mib = (memory / (1024 * 1024)).to_string();
+            let mut set_args = vec![
+                "set",
+                vm_name.as_str(),
+                "--cpus",
+                cpus.as_str(),
+                "--memsize",
+                memory_mib.as_str(),
+            ];
+            if self.data.headless {
+                set_args.extend(["--startup-view", "headless"]);
+            }
+            self.prlctl(&set_args)?;
+            self.configure_nics(&vm_name)?;
+            self.prlctl(&["start", &vm_name])
+        })();
+        if let Err(e) = result {
+            let _ = self.stop_vm(&vm_name).and_then(|_| 
self.prlctl(&["delete", &vm_name]));
+            return Err(e);
+        }
+        Ok(success("Instance created"))
+    }
+
+    fn start(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        self.prlctl(&["start", &name])?;
+        Ok(success("Instance started"))
+    }
+
+    fn stop(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        self.stop_vm(&name)?;
+        Ok(success("Instance stopped"))
+    }
+
+    fn reboot(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        self.prlctl(&["restart", &name])?;
+        Ok(success("Instance rebooted"))
+    }
+
+    fn delete(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        if self.vm_state(&name)?.is_some() {
+            self.stop_vm(&name)?;
+            self.prlctl(&["delete", &name])?;
+        }
+        Ok(success("Instance deleted"))
+    }
+
+    fn status(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        let state = self.vm_state(&name)?;
+        Ok(json!({"status": "success", "power_state": 
power_state(state.as_deref())}))
+    }
+
+    fn statuses(&mut self) -> Result<Value> {
+        let states: Map<String, Value> = self
+            .list_vms(false)?
+            .into_iter()
+            .map(|(name, state)| (name, 
Value::from(power_state(Some(&state)))))
+            .collect();
+        Ok(json!({"status": "success", "power_state": states}))
+    }
+
+    fn get_console(&mut self) -> Result<Value> {
+        Err("Operation not supported".into())
+    }
+
+    fn suspend(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        self.prlctl(&["pause", &name])?;
+        Ok(success("Instance suspended"))
+    }
+
+    fn resume(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        self.prlctl(&["resume", &name])?;
+        Ok(success("Instance resumed"))
+    }
+
+    fn get_ip_addresses(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        let output = self.prlctl(&["list", "--full", "--no-header", 
"--output", "ip", &name])?;
+        Ok(json!({"status": "success", "printmessage": "true", "message": 
parse_ip_addresses(&output)}))
+    }
+
+    /// Snapshots keyed by snapshot ID.
+    fn snapshots(&mut self, name: &str) -> Result<Vec<(String, Value)>> {
+        Ok(match self.prlctl_json(&["snapshot-list", name, "--json"])? {
+            Value::Object(map) => map.into_iter().collect(),
+            Value::Array(items) => items
+                .into_iter()
+                .map(|s| (value_str(s.get("id")).unwrap_or_default(), s))
+                .collect(),
+            _ => Vec::new(),
+        })
+    }
+
+    fn snapshot_id(&mut self, name: &str, snapshot_name: &str) -> 
Result<String> {
+        let matches: Vec<String> = self
+            .snapshots(name)?
+            .into_iter()
+            .filter(|(_, s)| s.get("name").and_then(Value::as_str) == 
Some(snapshot_name))
+            .map(|(id, _)| id)
+            .collect();
+        match matches.as_slice() {
+            [] => Err(format!("Snapshot '{snapshot_name}' not found")),
+            [id] => Ok(id.clone()),
+            _ => Err(format!("Multiple snapshots named '{snapshot_name}' 
found")),
+        }
+    }
+
+    fn list_snapshots(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        let snapshots: Vec<Value> = self
+            .snapshots(&name)?
+            .into_iter()
+            .map(|(_, s)| {
+                json!({
+                    "Name": value_str(s.get("name")).unwrap_or_default(),
+                    "CreationTime": 
value_str(s.get("date")).unwrap_or_default(),
+                    "Current": 
s.get("current").and_then(Value::as_bool).unwrap_or(false),
+                })
+            })
+            .collect();
+        Ok(json!({"status": "success", "printmessage": "true", "message": 
snapshots}))
+    }
+
+    fn create_snapshot(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        let snapshot = self.require_snapshot_name()?;
+        self.prlctl(&["snapshot", &name, "--name", &snapshot])?;
+        Ok(success(&format!("Snapshot '{snapshot}' created")))
+    }
+
+    fn restore_snapshot(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        let snapshot = self.require_snapshot_name()?;
+        let id = self.snapshot_id(&name, &snapshot)?;
+        self.prlctl(&["snapshot-switch", &name, "--id", &id])?;
+        Ok(success(&format!("Snapshot '{snapshot}' restored")))
+    }
+
+    fn delete_snapshot(&mut self) -> Result<Value> {
+        let name = self.require_vmname()?;
+        let snapshot = self.require_snapshot_name()?;
+        let id = self.snapshot_id(&name, &snapshot)?;
+        self.prlctl(&["snapshot-delete", &name, "--id", &id])?;
+        Ok(success(&format!("Snapshot '{snapshot}' deleted")))
+    }
+}
+
+fn load_config(path: &str) -> Result<Config> {
+    let content = std::fs::read_to_string(path).map_err(|e| match e.kind() {
+        std::io::ErrorKind::NotFound => format!("JSON file not found: {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())?;
+    Config::parse(&json_data)
+}
+
+fn execute(args: &[String]) -> Result<Value> {
+    if args.len() < 3 {
+        return Err("Usage: parallels <operation> '<json-file-path>'".into());
+    }
+    let operation = args[1].to_lowercase();
+    let mut manager = ParallelsManager {
+        data: load_config(&args[2])?,
+    };
+
+    match operation.as_str() {
+        "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(),
+        "suspend" => manager.suspend(),
+        "resume" => manager.resume(),
+        "getipaddresses" => manager.get_ip_addresses(),
+        "listsnapshots" => manager.list_snapshots(),
+        "createsnapshot" => manager.create_snapshot(),
+        "restoresnapshot" => manager.restore_snapshot(),
+        "deletesnapshot" => manager.delete_snapshot(),
+        _ => Err("Invalid action".into()),
+    }
+}
+
+fn main() {
+    let args: Vec<String> = std::env::args().collect();
+    match execute(&args) {
+        Ok(data) => println!("{data}"),
+        Err(error) => {
+            println!("{}", json!({"status": "error", "error": error}));
+            std::process::exit(1);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn payload(extension: Value) -> Value {
+        json!({
+            "externaldetails": {"extension": extension, "host": {"ssh_port": 
2222}, "virtualmachine": {}},
+            "cloudstack.vm.details": {
+                "name": "i-2-10-VM", "cpus": 2, "minRam": 2147483648u64,
+                "nics": [{"deviceId": 1, "mac": "02:00:00:00:00:02"}, 
{"deviceId": 0, "mac": "02:00:00:00:00:01"}]
+            },
+            "parameters": {"snapshot_name": " s1 "}
+        })
+    }
+
+    #[test]
+    fn parses_local_payload() {
+        let c = Config::parse(&payload(json!({"url": "localhost", 
"template_name": "tmpl"}))).unwrap();
+        assert!(c.local);
+        assert_eq!(c.ssh_port, "2222");
+        assert_eq!(c.vmname, "i-2-10-VM");
+        assert_eq!(c.cpus.as_deref(), Some("2"));
+        assert_eq!(c.memory, Some(2147483648));
+        assert_eq!(c.macs, vec!["02:00:00:00:00:01", "02:00:00:00:00:02"]);
+        assert_eq!(c.template_name, "tmpl");
+        assert_eq!(c.snapshot_name, "s1");
+        assert!(c.verify_host_key);
+        assert_eq!(c.wait_timeout, 120);
+    }
+
+    #[test]
+    fn rejects_invalid_payloads() {
+        
assert!(Config::parse(&payload(json!({}))).unwrap_err().contains("url"));
+        assert!(Config::parse(&payload(json!({"url": "mac.example"})))
+            .unwrap_err()
+            .contains("username"));
+        let mode = json!({"url": "localhost", "network_mode": "nat"});
+        assert!(Config::parse(&payload(mode))
+            .unwrap_err()
+            .contains("Invalid network_mode"));
+        let bridged = json!({"url": "localhost", "network_mode": "Bridged"});
+        assert!(Config::parse(&payload(bridged))
+            .unwrap_err()
+            .contains("bridge_interface"));
+    }
+
+    #[test]
+    fn quotes_like_shlex() {
+        assert_eq!(shell_quote(""), "''");
+        assert_eq!(shell_quote("i-2-10-VM"), "i-2-10-VM");
+        assert_eq!(shell_quote("Parallels Desktop.app"), "'Parallels 
Desktop.app'");
+        assert_eq!(shell_quote("it's"), "'it'\"'\"'s'");
+    }
+
+    #[test]
+    fn maps_power_states() {
+        assert_eq!(power_state(Some("running")), "poweron");
+        assert_eq!(power_state(Some("paused")), "poweron");
+        assert_eq!(power_state(Some("suspended")), "poweroff");
+        assert_eq!(power_state(Some("invalid")), "unknown");
+        assert_eq!(power_state(None), "unknown");
+    }
+
+    #[test]
+    fn orders_net_devices() {
+        let hardware = json!({"net10": {}, "cpu": {}, "net2": {}, "hdd0": {}, 
"netx": {}, "net0": {}});
+        assert_eq!(
+            net_devices(hardware.as_object().unwrap()),
+            vec!["net0", "net2", "net10"]
+        );
+    }
+
+    #[test]
+    fn parses_ip_addresses() {
+        assert_eq!(
+            parse_ip_addresses("10.211.55.3, fdb2::1\n"),
+            vec!["10.211.55.3", "fdb2::1"]
+        );
+        assert!(parse_ip_addresses("-\n").is_empty());
+    }
+
+    #[test]
+    fn builds_ssh_command() {
+        let mut ext = json!({"url": "mac.example", "username": "dev", 
"verify_host_key": "false"});
+        let c = Config::parse(&payload(ext.take())).unwrap();
+        let cmd = ParallelsManager { data: c }.ssh_command(&["prlctl".into(), 
"list".into(), "my vm".into()]);
+        assert_eq!(cmd[0], "ssh");
+        assert!(cmd.contains(&"BatchMode=yes".to_string()));
+        assert!(cmd.contains(&"UserKnownHostsFile=/dev/null".to_string()));
+        assert_eq!(
+            cmd[cmd.len() - 2..],
+            ["--".to_string(), "prlctl list 'my vm'".to_string()]
+        );
+    }
+}

Reply via email to