This is an automated email from the ASF dual-hosted git repository. DaanHoogland pushed a commit to branch parallels-extension in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git
commit 5ea6951ad2a833291dfa8d7669901749bc32d2fc Author: Daan Hoogland <[email protected]> AuthorDate: Fri Oct 2 18:18:31 2026 +0200 parallels: add a script for Parallels as hypervisor extension --- parallels/README.txt | 143 ++++++++++++++++ parallels/parallels.py | 453 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 596 insertions(+) diff --git a/parallels/README.txt b/parallels/README.txt new file mode 100644 index 0000000..258a712 --- /dev/null +++ b/parallels/README.txt @@ -0,0 +1,143 @@ +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 +======================================== + +Orchestrator extension that lets Apache CloudStack manage virtual machines +in Parallels Desktop (https://www.parallels.com/products/desktop/) on macOS. +Each macOS machine running Parallels Desktop is added to CloudStack as a +host in a cluster mapped to this extension. + +The script 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 +------------ + +Management server: + - python3 (standard library only) + - 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 + +Setup +----- + +1. Copy parallels.py to every management server: + + mkdir -p /usr/share/cloudstack-management/extensions/Parallels + cp parallels.py /usr/share/cloudstack-management/extensions/Parallels/parallels.py + chmod 755 /usr/share/cloudstack-management/extensions/Parallels/parallels.py + 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.py + 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 + (status, statuses, delete of a missing VM, error handling). create, NIC + reconfiguration and the snapshot actions still need verification on a + Pro/Business installation. diff --git a/parallels/parallels.py b/parallels/parallels.py new file mode 100755 index 0000000..8f04e63 --- /dev/null +++ b/parallels/parallels.py @@ -0,0 +1,453 @@ +#!/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 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. + +Details (host details override extension details): + url Mac hostname/IP, or "localhost" to run locally + username SSH user owning the Parallels 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" + prlctl_path optional, by default /usr/local/bin/prlctl or the one + in /Applications/Parallels Desktop.app is used, + falling back to a Spotlight lookup of the app bundle + template_name optional, default Parallels VM or template to clone + linked_clone optional, "true" to create linked clones (default + "false"); the template VM needs a snapshot for this + network_mode optional, shared|bridged|host-only; when set it is + applied to every NIC, otherwise the template's mode is + kept and added NICs use "shared" + bridge_interface optional, 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 (120) + +Instance/template details: + template_name Parallels VM to clone, overrides the host/extension value + +Custom action parameters: + snapshot_name name of the snapshot for Create/Restore/DeleteSnapshot +""" + +import json +import os +import shlex +import subprocess +import sys +import time + +DEFAULT_PRLCTL_PATHS = ("/usr/local/bin/prlctl", + "/Applications/Parallels Desktop.app/Contents/MacOS/prlctl") +PARALLELS_BUNDLE_ID = "com.parallels.desktop.console" +NETWORK_MODES = ("shared", "bridged", "host-only") +LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1") + +POWER_ON_STATES = ("running", "starting", "paused", "pausing", "continuing", "resuming", "resetting") +POWER_OFF_STATES = ("stopped", "suspended") + + +def fail(message): + print(json.dumps({"status": "error", "error": message})) + sys.exit(1) + + +def succeed(data): + print(json.dumps(data)) + sys.exit(0) + + +class ParallelsError(Exception): + pass + + +class ParallelsManager: + 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"), + "prlctl": detail("prlctl_path"), + "linked_clone": flag("linked_clone"), + "network_mode": detail("network_mode").lower(), + "bridge_interface": detail("bridge_interface"), + "headless": flag("headless"), + "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)}") + if data["network_mode"] == "bridged" and not data["bridge_interface"]: + fail("Missing required field in JSON: bridge_interface (required for bridged network_mode)") + 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")] + + parameters = json_data.get("parameters", {}) or {} + data["snapshot_name"] = str(parameters.get("snapshot_name", "")).strip() + 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): + 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, + timeout=self.data["wait_timeout"] + 30, env=env) + except FileNotFoundError as e: + raise ParallelsError(f"Command not found: {e.filename}") + except subprocess.TimeoutExpired: + raise ParallelsError(f"Timed out running: {' '.join(argv)}") + if r.returncode != 0: + raise ParallelsError((r.stderr or r.stdout).strip() or f"'{' '.join(argv)}' exited with {r.returncode}") + return r.stdout + + def resolve_prlctl(self): + candidates = " ".join(shlex.quote(p) for p in DEFAULT_PRLCTL_PATHS) + script = (f'for p in {candidates}; do [ -x "$p" ] && echo "$p" && exit 0; done; ' + f'p="$(mdfind "kMDItemCFBundleIdentifier == \'{PARALLELS_BUNDLE_ID}\'" | head -n 1)/Contents/MacOS/prlctl"; ' + '[ -x "$p" ] && echo "$p"') + try: + path = self.run(["sh", "-c", script]).strip() + except ParallelsError: + path = "" + if not path: + raise ParallelsError("Parallels Desktop not found on the host, set the prlctl_path detail") + return path + + def prlctl(self, *args): + if not self.data["prlctl"]: + self.data["prlctl"] = self.resolve_prlctl() + return self.run([self.data["prlctl"]] + list(args)) + + def prlctl_json(self, *args): + output = self.prlctl(*args).strip() + if not output: + return None + try: + return json.loads(output) + except json.JSONDecodeError: + raise ParallelsError(f"Failed to parse prlctl output: {output}") + + def list_vms(self, templates=False): + args = ["list", "--all", "--json"] + (["--template"] if templates else []) + return {vm["name"]: vm.get("status", "").lower() for vm in self.prlctl_json(*args) or [] if vm.get("name")} + + def vm_state(self, name=None): + return self.list_vms().get(name or self.data["vmname"]) + + def vm_info(self, name): + info = self.prlctl_json("list", "--info", "--json", name) + if isinstance(info, list): + info = info[0] if info else {} + return info or {} + + def wait_for_state(self, states, name=None): + deadline = time.time() + self.data["wait_timeout"] + while True: + state = self.vm_state(name) + if state in states: + return state + if time.time() > deadline: + raise ParallelsError(f"Timed out waiting for {name or self.data['vmname']} to reach {'/'.join(states)}, " + f"current state: {state}") + time.sleep(2) + + def require_vmname(self): + if not self.data["vmname"]: + fail("Missing required field in JSON: cloudstack.vm.details.name") + + def require_snapshot_name(self): + if not self.data["snapshot_name"]: + fail("Missing required field in JSON: snapshot_name") + + def stop_vm(self, name): + state = self.vm_state(name) + if state is None or state == "stopped": + return + if state == "suspended": + self.prlctl("stop", name, "--drop-state") + return + try: + self.prlctl("stop", name) + self.wait_for_state(("stopped",), name) + except ParallelsError: + self.prlctl("stop", name, "--kill") + self.wait_for_state(("stopped",), name) + + def configure_nics(self, vm_name): + hardware = self.vm_info(vm_name).get("Hardware", {}) or {} + existing = sorted((d for d in hardware if d.startswith("net") and d[3:].isdigit()), key=lambda d: int(d[3:])) + mode = self.data["network_mode"] + mode_args = [] + if mode: + mode_args = ["--type", mode] + if mode == "bridged": + mode_args += ["--iface", self.data["bridge_interface"]] + for i, mac in enumerate(self.data["macs"]): + mac = mac.replace(":", "").upper() + if i < len(existing): + self.prlctl("set", vm_name, "--device-set", existing[i], "--mac", mac, *mode_args) + else: + add_args = mode_args or ["--type", "shared"] + self.prlctl("set", vm_name, "--device-add", "net", "--mac", mac, *add_args) + for device in existing[len(self.data["macs"]):]: + self.prlctl("set", vm_name, "--device-del", device) + + 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") + + vms = self.list_vms(templates=True) + if template not in vms: + fail(f"Template VM '{template}' not found in Parallels Desktop") + if vm_name in vms: + fail(f"A Parallels VM named '{vm_name}' already exists") + + cloned = False + try: + clone_args = ["clone", template, "--name", vm_name] + if self.data["linked_clone"]: + clone_args.append("--linked") + self.prlctl(*clone_args) + cloned = True + memory_mib = int(self.data["memory"]) // (1024 * 1024) + set_args = ["set", vm_name, "--cpus", str(self.data["cpus"]), "--memsize", str(memory_mib)] + if self.data["headless"]: + set_args += ["--startup-view", "headless"] + self.prlctl(*set_args) + self.configure_nics(vm_name) + self.prlctl("start", vm_name) + succeed({"status": "success", "message": "Instance created"}) + except ParallelsError as e: + if cloned: + try: + self.stop_vm(vm_name) + self.prlctl("delete", vm_name) + except ParallelsError: + pass + fail(str(e)) + + def start(self): + self.require_vmname() + self.prlctl("start", self.data["vmname"]) + succeed({"status": "success", "message": "Instance started"}) + + def stop(self): + self.require_vmname() + self.stop_vm(self.data["vmname"]) + succeed({"status": "success", "message": "Instance stopped"}) + + def reboot(self): + self.require_vmname() + self.prlctl("restart", self.data["vmname"]) + succeed({"status": "success", "message": "Instance rebooted"}) + + def delete(self): + self.require_vmname() + vm_name = self.data["vmname"] + if self.vm_state(vm_name) is not None: + self.stop_vm(vm_name) + self.prlctl("delete", vm_name) + succeed({"status": "success", "message": "Instance deleted"}) + + @staticmethod + def power_state(state): + if state in POWER_ON_STATES: + return "poweron" + if state in POWER_OFF_STATES: + return "poweroff" + return "unknown" + + def status(self): + self.require_vmname() + succeed({"status": "success", "power_state": self.power_state(self.vm_state())}) + + def statuses(self): + power_state = {name: self.power_state(state) for name, state in self.list_vms().items()} + succeed({"status": "success", "power_state": power_state}) + + def get_console(self): + fail("Operation not supported") + + def suspend(self): + self.require_vmname() + self.prlctl("pause", self.data["vmname"]) + succeed({"status": "success", "message": "Instance suspended"}) + + def resume(self): + self.require_vmname() + self.prlctl("resume", self.data["vmname"]) + succeed({"status": "success", "message": "Instance resumed"}) + + def get_ip_addresses(self): + self.require_vmname() + output = self.prlctl("list", "--full", "--no-header", "--output", "ip", self.data["vmname"]) + addresses = [a for a in output.replace(",", " ").split() if a != "-"] + succeed({"status": "success", "printmessage": "true", "message": addresses}) + + def snapshots(self): + snapshots = self.prlctl_json("snapshot-list", self.data["vmname"], "--json") or {} + if isinstance(snapshots, list): + snapshots = {s.get("id", ""): s for s in snapshots} + return snapshots + + def snapshot_id(self): + matches = [sid for sid, s in self.snapshots().items() if s.get("name") == self.data["snapshot_name"]] + if not matches: + fail(f"Snapshot '{self.data['snapshot_name']}' not found") + if len(matches) > 1: + fail(f"Multiple snapshots named '{self.data['snapshot_name']}' found") + return matches[0] + + def list_snapshots(self): + self.require_vmname() + snapshots = [{"Name": s.get("name", ""), "CreationTime": s.get("date", ""), "Current": bool(s.get("current"))} + for s in self.snapshots().values()] + succeed({"status": "success", "printmessage": "true", "message": snapshots}) + + def create_snapshot(self): + self.require_vmname() + self.require_snapshot_name() + self.prlctl("snapshot", self.data["vmname"], "--name", self.data["snapshot_name"]) + succeed({"status": "success", "message": f"Snapshot '{self.data['snapshot_name']}' created"}) + + def restore_snapshot(self): + self.require_vmname() + self.require_snapshot_name() + self.prlctl("snapshot-switch", self.data["vmname"], "--id", self.snapshot_id()) + succeed({"status": "success", "message": f"Snapshot '{self.data['snapshot_name']}' restored"}) + + def delete_snapshot(self): + self.require_vmname() + self.require_snapshot_name() + self.prlctl("snapshot-delete", self.data["vmname"], "--id", self.snapshot_id()) + succeed({"status": "success", "message": f"Snapshot '{self.data['snapshot_name']}' deleted"}) + + +def main(): + if len(sys.argv) < 3: + fail("Usage: parallels.py <operation> '<json-file-path>'") + + operation = sys.argv[1].lower() + json_file_path = sys.argv[2] + + try: + manager = ParallelsManager(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, + "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, + } + + if operation not in operations: + fail("Invalid action") + + try: + operations[operation]() + except ParallelsError as e: + fail(str(e)) + + +if __name__ == "__main__": + main()
