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()

Reply via email to