You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cloudstack.apache.org by da...@apache.org on 2026/10/02 16:19:32 UTC

(cloudstack-extensions) branch parallels-extension created (now 5ea6951)

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

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


      at 5ea6951  parallels: add a script for Parallels as hypervisor extension

This branch includes the following new commits:

     new 5ea6951  parallels: add a script for Parallels as hypervisor extension

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(cloudstack-extensions) 01/01: parallels: add a script for Parallels as hypervisor extension

Posted by da...@apache.org.
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 <da...@apache.org>
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()