blob: 8f04e63276f1853f8c94f64ca03cf27439893b70 [file]
#!/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()